diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 3b1c82ba..b19d4be6 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -14,6 +14,26 @@ on: type: string required: true default: 'linux/amd64,linux/arm64' + image-name: + description: 'Image name (e.g., ghcr.io/endorhq/rover/agent)' + type: string + required: false + default: 'ghcr.io/endorhq/rover/agent' + workflow_call: + inputs: + tag: + description: 'Image tag (e.g., v1.0.0, latest)' + type: string + required: true + platforms: + description: 'Target platforms' + type: string + required: false + default: 'linux/amd64,linux/arm64' + image-name: + description: 'Image name (e.g., ghcr.io/endorhq/rover/agent)' + type: string + required: false jobs: build-and-push: @@ -51,7 +71,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }}/node + images: ${{ inputs.image-name || format('ghcr.io/{0}/agent', github.repository) }} tags: | type=raw,value=${{ inputs.tag }} @@ -59,7 +79,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: ./images/node/Dockerfile + file: ./images/agent/Dockerfile platforms: ${{ inputs.platforms }} push: true tags: ${{ steps.meta.outputs.tags }} @@ -71,7 +91,7 @@ jobs: if: success() env: IMAGE_TAG: ${{ inputs.tag }} - IMAGE_URL: 'ghcr.io/${{ github.repository }}/node:${{ inputs.tag }}' + IMAGE_URL: ${{ inputs.image-name || format('ghcr.io/{0}/agent', github.repository) }}:${{ inputs.tag }} run: | echo "✅ Successfully built and pushed Docker image" echo "Image: ${IMAGE_URL}" diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index ce54ba78..bda20695 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -45,3 +45,14 @@ jobs: - name: Test Build Artifact run: node dist/index.js --version working-directory: packages/cli + + build-dev-image: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + uses: ./.github/workflows/build-image.yml + permissions: + contents: read + packages: write + with: + tag: latest + image-name: ghcr.io/${{ github.repository }}/agent-dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 407529ff..58582216 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,18 @@ on: default: false jobs: + build-image: + if: inputs.project == 'cli' && inputs.dryRun == false + uses: ./.github/workflows/build-image.yml + with: + tag: v${{ inputs.targetVersion }} + permissions: + contents: read + packages: write + release: + needs: build-image + if: always() && (needs.build-image.result == 'success' || needs.build-image.result == 'skipped') timeout-minutes: 15 environment: production runs-on: ubuntu-latest @@ -58,6 +69,33 @@ jobs: with: node-version: 22 + - name: Create or Update Release Branch + run: | + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor }}@users.noreply.github.com" + + # Extract major.minor from version (e.g., 1.2.3 -> 1.2) + VERSION="${{ inputs.targetVersion }}" + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + BRANCH_NAME="release/${{ inputs.project }}/v${MAJOR_MINOR}" + + echo "Target version: $VERSION" + echo "Release branch: $BRANCH_NAME" + + # Fetch remote branches to check if release branch exists + git fetch origin + + # Check if branch exists remotely + if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME already exists, checking it out..." + git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" + else + echo "Creating new branch $BRANCH_NAME..." + git checkout -b "$BRANCH_NAME" + fi + + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + - name: Bump Version run: npm version ${{ inputs.targetVersion }} working-directory: ./packages/${{ inputs.project }} @@ -92,15 +130,13 @@ jobs: PROJECT: '${{ inputs.project }}' TAG_NAME: '${{ inputs.project }}/v${{ inputs.targetVersion }}' run: | - git config user.name "${{ github.actor }}" - git config user.email "${{ github.actor }}@users.noreply.github.com" git add . - git commit -m "bump: update $PROJECT to $VERSION" + git commit -m "release: bump $PROJECT to $VERSION" COMMIT_SHA=$(git rev-parse HEAD) echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV echo "Created commit: $COMMIT_SHA" git tag -a "$TAG_NAME" -m "$TAG_NAME" - git push + git push origin "${{ env.BRANCH_NAME }}" git push --tags - name: (Dry Run) Create the release diff --git a/docs/agent-images.md b/docs/agent-images.md new file mode 100644 index 00000000..293b8cc0 --- /dev/null +++ b/docs/agent-images.md @@ -0,0 +1,198 @@ +# Agent images + +Agents in Rover run in isolated environments ([sandbox](https://docs.endor.dev/rover/concepts/sandbox/)). These environments give agents a way to modify the environment to improve, implement, build, check, and test a given project, without impacting the host machine. Rover uses Alpine linux as the base image for the default sandbox images we provide. The main reason is to keep these images minimal and give agents access to a rich package ecosystem (`apk`). + +## Agent images + +There are two types of images: development and tagged images. In general, the development images are used. Tagged images are only used when a new rover release is created. + +### Base image + +The base image is Alpine Linux as it is a good fit for Agents in the spirit of being developer friendly, using less disk space, and because it's built for simplicity. It also has a very rich [package offering](https://pkgs.alpinelinux.org/packages). + +The base image is built with the following [Dockerfile](../images/agent/Dockerfile). + +#### Agent installation + +In general, when a new agent session is requested within the container, we install it with `npm install -g` or with the instructions provided by the Agent maintainers. + +In some circumstances this has not been possible, because of incompatibilities. Such as an example is the [Cursor Agent](https://forum.cursor.com/t/cursor-agent-does-not-work-with-non-glibc-based-distributions-such-as-alpine-linux/141571). For those cases, we have `nix` based setup (package also installed with the `Dockerfile`). + +By having the `cursor-agent` through `nix`, we are able to pull a compatible glibc, and use that without any issues. + +### Development images + +The code at the `main` branch points to `ghcr.io/endorhq/rover/agent-dev:latest`. Automation builds and pushes to this image when a new commit is pushed to the `main` branch. + +### Tagged images + +Whenever we create a new tag in Rover, a new image will be pushed with that tag name at: `ghcr.io/endorhq/rover/agent:`. + +Note that tagged images are named `agent` as opposed to development images that are named `agent-dev`. This is so that when a new tag is pushed to the `ghcr.io/endorhq/rover/agent` image, the `ghcr.io/endorhq/rover/agent-dev:latest` image is not affected. + +#### Release + +Releasing a new tag for an image is done through the [Release workflow](../.github/workflows/release.yml). This workflow will tag the source code, as well as build a new agent image, with the source code updated to point to that agent image + +## Develop a new agent image + +Some changes might require updating the `rover-agent` CLI that runs in the container during development, or we might want to update the base image or perform some changes to it. In that case, a new image of the `rover-agent` image has to be built. + +### Minimum image requirements + +In case that you are experimenting to build a very different agent image than the current one, the minimum requirements for the image follows. + +#### Node + +Regardless of the base image you use, Node 24 is a prerequisite, as [`rover-agent`](https://github.com/endorhq/rover/tree/main/packages/agent) is a Node application. + +#### Package Manager MCP + +The [package-manager MCP](https://github.com/endorhq/package-manager-mcp) is a static binary that allows the configured agent to search and install packages in the container. + +It is expected that a binary named `package-manager-mcp-server` exists in the `$PATH`. It will be configured during the agent set up phase. + +You can find the static binaries in the [Releases list](https://github.com/endorhq/package-manager-mcp/releases). + +#### Reserved directories + +Reserved directories may or may not exist in the container image. However, their original contents won't be available during Agent execution, as host paths will be mounted in these directories automatically by Rover. + +- `/workspace`: mountpoint used for the user project. + +- `/output`: mountpoint used by Rover to follow the task progress. + +#### Sudo + +`sudo` needs to be available in the system. The reason behind this decision is because we need to run Agents in an unattended mode so that they can finish the task without asking many intermediate questions. However, usually, inside a container, we are identified as the `root` user. Many agents will refuse to run if they are super user, so that we run the agent with an unprivileged user, and use `sudo` with it. + +In rootless containers, we do use `sudo` as well. + +The Rover agent CLI goes through two main steps: + +1. Setting up the environment and installing system dependencies +2. Running the chosen agent + +Ideally, there should be two `sudo` profiles: + +- `/etc/sudoers.d/1-agent-setup` +- `/etc/sudoers.d/2-agent-cleanup` + +The contents of this files will depend on the default groups present in the base image to be configured for Rover. However, a good rule of thumb is to take `/etc/group` from the base and configure both accordingly, adding an extra group `agent` that will be created automatically by Rover if necessary. + +Rover will remove `/etc/sudoers.d/1-agent-setup` before handing control to the agent. From that point on, the +`/etc/sudoers.d/2-agent-cleanup` will determine what the agent is able to do with `sudo`: it is highly recommended to reduce the list of commands that could be executed with root permissions without password. + +An example for `node:24-alpine` follows: + +
+ +/etc/sudoers.d/1-agent-setup + +``` +# Rover agent group; if there is no matching gid within the container +# with the host gid, the `agent` group will be used. + +%agent ALL=(ALL) NOPASSWD: ALL + +# Original image group list at /etc/group. If the host user gid +# matches with any of them, it will be able to use `sudo` normally +# within the container. + +%root ALL=(ALL) NOPASSWD: ALL +%bin ALL=(ALL) NOPASSWD: ALL +%daemon ALL=(ALL) NOPASSWD: ALL +%sys ALL=(ALL) NOPASSWD: ALL +%adm ALL=(ALL) NOPASSWD: ALL +%tty ALL=(ALL) NOPASSWD: ALL +%disk ALL=(ALL) NOPASSWD: ALL +%lp ALL=(ALL) NOPASSWD: ALL +%kmem ALL=(ALL) NOPASSWD: ALL +%wheel ALL=(ALL) NOPASSWD: ALL +%floppy ALL=(ALL) NOPASSWD: ALL +%mail ALL=(ALL) NOPASSWD: ALL +%news ALL=(ALL) NOPASSWD: ALL +%uucp ALL=(ALL) NOPASSWD: ALL +%cron ALL=(ALL) NOPASSWD: ALL +%audio ALL=(ALL) NOPASSWD: ALL +%cdrom ALL=(ALL) NOPASSWD: ALL +%dialout ALL=(ALL) NOPASSWD: ALL +%ftp ALL=(ALL) NOPASSWD: ALL +%sshd ALL=(ALL) NOPASSWD: ALL +%input ALL=(ALL) NOPASSWD: ALL +%tape ALL=(ALL) NOPASSWD: ALL +%video ALL=(ALL) NOPASSWD: ALL +%netdev ALL=(ALL) NOPASSWD: ALL +%kvm ALL=(ALL) NOPASSWD: ALL +%games ALL=(ALL) NOPASSWD: ALL +%shadow ALL=(ALL) NOPASSWD: ALL +%www-data ALL=(ALL) NOPASSWD: ALL +%users ALL=(ALL) NOPASSWD: ALL +%ntp ALL=(ALL) NOPASSWD: ALL +%abuild ALL=(ALL) NOPASSWD: ALL +%utmp ALL=(ALL) NOPASSWD: ALL +%ping ALL=(ALL) NOPASSWD: ALL +%nogroup ALL=(ALL) NOPASSWD: ALL +%nobody ALL=(ALL) NOPASSWD: ALL +%node ALL=(ALL) NOPASSWD: ALL +%nix ALL=(ALL) NOPASSWD: ALL +%nixbld ALL=(ALL) NOPASSWD: ALL +``` + +
+ +
+ +/etc/sudoers.d/2-agent-cleanup + +``` +# Rover agent group; if there is no matching gid within the container +# with the host gid, the `agent` group will be used. + +%agent ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee + +# Original image group list at /etc/group. If the host user gid +# matches with any of them, it will be able to use `sudo` normally +# within the container. + +%root ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%bin ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%daemon ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%sys ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%adm ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%tty ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%disk ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%lp ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%kmem ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%wheel ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%floppy ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%mail ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%news ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%uucp ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%cron ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%audio ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%cdrom ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%dialout ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%ftp ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%sshd ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%input ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%tape ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%video ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%netdev ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%kvm ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%games ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%shadow ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%www-data ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%users ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%ntp ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%abuild ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%utmp ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%ping ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%nogroup ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%nobody ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%node ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%nix ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +%nixbld ALL=(ALL) NOPASSWD: /bin/chown,/bin/cp,/bin/mv,/usr/bin/tee +``` + +
diff --git a/docs/ai-agents.md b/docs/ai-agents.md index dba3be03..ffe64afe 100644 --- a/docs/ai-agents.md +++ b/docs/ai-agents.md @@ -114,7 +114,7 @@ A new agent touches different packages in the project. To test your changes, run ```bash # Remember to change "VERSION" with the value from "AGENT_IMAGE" - docker build -t ghcr.io/endorhq/rover/node:VERSION -f ./images/node/Dockerfile . + docker build -t ghcr.io/endorhq/rover/node:VERSION -f ./images/agent/Dockerfile . ``` This image builds the new `agent` package and installs it in the image. We build the final image and publish it during the production release. diff --git a/images/node/Dockerfile b/images/agent/Dockerfile similarity index 77% rename from images/node/Dockerfile rename to images/agent/Dockerfile index b7fc8cde..e5b64cba 100644 --- a/images/node/Dockerfile +++ b/images/agent/Dockerfile @@ -9,7 +9,7 @@ RUN cd ./packages/agent && npm pack && mv *.tgz /rover-agent.tgz FROM node:24-alpine ARG NIX_AI_TOOLS_REV ARG TARGETARCH -ARG PACKAGE_MANAGER_MCP_SERVER_VERSION="v0.1.3" +ARG PACKAGE_MANAGER_MCP_SERVER_VERSION="v0.2.0" ENV NIX_AI_TOOLS_REV=${NIX_AI_TOOLS_REV} RUN apk update && \ apk add bash file jq nix python3 sudo uv && \ @@ -27,9 +27,9 @@ COPY --from=agent-builder /rover-agent.tgz /rover-agent.tgz RUN npm install -g mcp-remote@0.1.29 RUN npm install -g /rover-agent.tgz -COPY ./images/node/assets/1-sudoers-setup /etc/sudoers.d/1-agent-setup -COPY ./images/node/assets/2-sudoers-cleanup /etc/sudoers.d/2-agent-cleanup +COPY ./images/agent/assets/1-sudoers-setup /etc/sudoers.d/1-agent-setup +COPY ./images/agent/assets/2-sudoers-cleanup /etc/sudoers.d/2-agent-cleanup # Install agents with special requirements -COPY ./images/node/assets/nix/nix.conf /etc/nix/nix.conf -COPY ./images/node/assets/nix/ai-tool-wrapper /usr/local/bin/cursor-agent +COPY ./images/agent/assets/nix/nix.conf /etc/nix/nix.conf +COPY ./images/agent/assets/nix/ai-tool-wrapper /usr/local/bin/cursor-agent diff --git a/images/node/assets/1-sudoers-setup b/images/agent/assets/1-sudoers-setup similarity index 100% rename from images/node/assets/1-sudoers-setup rename to images/agent/assets/1-sudoers-setup diff --git a/images/node/assets/2-sudoers-cleanup b/images/agent/assets/2-sudoers-cleanup similarity index 100% rename from images/node/assets/2-sudoers-cleanup rename to images/agent/assets/2-sudoers-cleanup diff --git a/images/node/assets/nix/ai-tool-wrapper b/images/agent/assets/nix/ai-tool-wrapper similarity index 100% rename from images/node/assets/nix/ai-tool-wrapper rename to images/agent/assets/nix/ai-tool-wrapper diff --git a/images/node/assets/nix/nix.conf b/images/agent/assets/nix/nix.conf similarity index 100% rename from images/node/assets/nix/nix.conf rename to images/agent/assets/nix/nix.conf diff --git a/package-lock.json b/package-lock.json index 25d569d4..ed77faad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9528,7 +9528,7 @@ }, "packages/cli": { "name": "@endorhq/rover", - "version": "1.5.1", + "version": "1.6.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.1", @@ -9588,7 +9588,7 @@ }, "packages/extension": { "name": "endor-rover", - "version": "1.4.0", + "version": "0.0.0-dev", "license": "Apache-2.0", "dependencies": { "@vscode/codicons": "^0.0.39", diff --git a/packages/agent/src/lib/agents/cursor.ts b/packages/agent/src/lib/agents/cursor.ts index 219d23c5..a7372349 100644 --- a/packages/agent/src/lib/agents/cursor.ts +++ b/packages/agent/src/lib/agents/cursor.ts @@ -1,10 +1,10 @@ import { existsSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, platform } from 'node:os'; import colors from 'ansi-colors'; import { AgentCredentialFile } from './types.js'; import { BaseAgent } from './base.js'; -import { launch } from 'rover-common'; +import { launch, launchSync } from 'rover-common'; import { mcpJsonSchema } from '../mcp/schema.js'; export class CursorAgent extends BaseAgent { @@ -20,7 +20,7 @@ export class CursorAgent extends BaseAgent { { path: '/.cursor/cli-config.json', description: 'Cursor configuration', - required: true, + required: false, }, { path: '/.config/cursor/auth.json', @@ -36,6 +36,7 @@ export class CursorAgent extends BaseAgent { this.ensureDirectory(join(targetDir, '.cursor')); this.ensureDirectory(join(targetDir, '.config', 'cursor')); + // Copy existing credential files const credentials = this.getRequiredCredentials(); for (const cred of credentials) { if (existsSync(cred.path)) { diff --git a/packages/cli/package.json b/packages/cli/package.json index 619e932a..f97b4545 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@endorhq/rover", - "version": "1.5.1", + "version": "1.6.0", "description": "A manager for AI coding agents that works with Claude Code, Gemini, and Qwen.", "main": "index.js", "type": "module", diff --git a/packages/cli/src/commands/__tests__/delete.test.ts b/packages/cli/src/commands/__tests__/delete.test.ts index 53aad76b..4ba46af8 100644 --- a/packages/cli/src/commands/__tests__/delete.test.ts +++ b/packages/cli/src/commands/__tests__/delete.test.ts @@ -103,7 +103,6 @@ describe('delete command', () => { "Invalid task ID 'invalid' - must be a number", ]), }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -121,7 +120,6 @@ describe('delete command', () => { "Invalid task ID '' - must be a number", ]), }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -139,7 +137,6 @@ describe('delete command', () => { errors: ['Task with ID 1 was not found'], success: false, }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -158,7 +155,6 @@ describe('delete command', () => { errors: ['Task with ID 999 was not found'], success: false, }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -175,7 +171,6 @@ describe('delete command', () => { errors: ['Task with ID -1 was not found'], success: false, }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -204,7 +199,6 @@ describe('delete command', () => { success: true, errors: [], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -224,7 +218,6 @@ describe('delete command', () => { success: true, errors: [], }, - true, expect.objectContaining({ telemetry: expect.anything(), }) @@ -258,7 +251,6 @@ describe('delete command', () => { success: true, errors: [], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -284,7 +276,6 @@ describe('delete command', () => { success: false, errors: ['Task deletion cancelled'], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -312,7 +303,6 @@ describe('delete command', () => { success: false, errors: ['Task deletion cancelled'], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -339,7 +329,6 @@ describe('delete command', () => { success: true, errors: [], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -363,7 +352,6 @@ describe('delete command', () => { success: true, errors: [], }, - true, expect.objectContaining({ telemetry: expect.anything(), }) @@ -471,7 +459,6 @@ describe('delete command', () => { success: true, errors: [], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -489,7 +476,6 @@ describe('delete command', () => { errors: ['Task with ID 0 was not found'], success: false, }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -506,7 +492,6 @@ describe('delete command', () => { errors: ['Task with ID 999999999 was not found'], success: false, }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -528,7 +513,6 @@ describe('delete command', () => { success: true, errors: [], }, - true, // JSON mode expect.objectContaining({ telemetry: expect.anything(), }) @@ -557,7 +541,6 @@ describe('delete command', () => { success: true, errors: [], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -582,7 +565,6 @@ describe('delete command', () => { success: true, errors: ['Task with ID 999 was not found'], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -609,7 +591,6 @@ describe('delete command', () => { success: false, errors: ['Task deletion cancelled'], }, - false, expect.objectContaining({ telemetry: expect.anything(), }) diff --git a/packages/cli/src/commands/__tests__/init.e2e.test.ts b/packages/cli/src/commands/__tests__/init.e2e.test.ts index 3dcf8e1b..1820280a 100644 --- a/packages/cli/src/commands/__tests__/init.e2e.test.ts +++ b/packages/cli/src/commands/__tests__/init.e2e.test.ts @@ -104,7 +104,7 @@ describe('rover init (e2e)', () => { USER: process.env.USER, TMPDIR: process.env.TMPDIR, // Disable telemetry for tests - ROVER_TELEMETRY_DISABLED: '1', + ROVER_NO_TELEMETRY: '1', }, reject: false, // Don't throw on non-zero exit }); @@ -465,7 +465,7 @@ tasks: HOME: process.env.HOME, USER: process.env.USER, TMPDIR: process.env.TMPDIR, - ROVER_TELEMETRY_DISABLED: '1', + ROVER_NO_TELEMETRY: '1', }, reject: false, }); diff --git a/packages/cli/src/commands/__tests__/init.test.ts b/packages/cli/src/commands/__tests__/init.test.ts index 76464775..456f804a 100644 --- a/packages/cli/src/commands/__tests__/init.test.ts +++ b/packages/cli/src/commands/__tests__/init.test.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { clearProjectRootCache, launchSync } from 'rover-common'; import { initCommand } from '../init.js'; -import { CURRENT_PROJECT_SCHEMA_VERSION } from '../../lib/config.js'; +import { CURRENT_PROJECT_SCHEMA_VERSION } from 'rover-schemas'; // Mock only the external tool checks (except Git which should be available) vi.mock('../../utils/system.js', async () => { diff --git a/packages/cli/src/commands/__tests__/logs.test.ts b/packages/cli/src/commands/__tests__/logs.test.ts index 16f661db..30d053ed 100644 --- a/packages/cli/src/commands/__tests__/logs.test.ts +++ b/packages/cli/src/commands/__tests__/logs.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { clearProjectRootCache } from 'rover-common'; import { logsCommand } from '../logs.js'; import { TaskDescriptionManager } from 'rover-schemas'; +import { setJsonMode } from '../../lib/global-state.js'; // Mock external dependencies vi.mock('../../lib/telemetry.js', () => ({ @@ -80,6 +81,7 @@ describe('logs command', () => { rmSync(testDir, { recursive: true, force: true }); vi.clearAllMocks(); clearProjectRootCache(); + setJsonMode(false); }); // Helper to create a test task with container ID @@ -136,7 +138,6 @@ describe('logs command', () => { expect.objectContaining({ error: "Invalid task ID 'invalid' - must be a number", }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -152,7 +153,6 @@ describe('logs command', () => { expect.objectContaining({ error: "Invalid task ID '' - must be a number", }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -169,7 +169,6 @@ describe('logs command', () => { expect.objectContaining({ error: 'The task with ID 1 was not found', }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -187,7 +186,6 @@ describe('logs command', () => { expect.objectContaining({ error: 'The task with ID 999 was not found', }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -203,7 +201,6 @@ describe('logs command', () => { expect.objectContaining({ error: 'The task with ID -1 was not found', }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -224,7 +221,6 @@ describe('logs command', () => { expect.objectContaining({ error: "Invalid iteration number: 'invalid'", }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -244,7 +240,6 @@ describe('logs command', () => { error: "Iteration 5 not found for task '2'. Available iterations: 1, 2", }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -270,7 +265,6 @@ describe('logs command', () => { logs: '', success: false, }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -294,7 +288,6 @@ describe('logs command', () => { logs: '', success: false, }), - true, expect.objectContaining({ telemetry: expect.anything(), }) @@ -317,7 +310,6 @@ describe('logs command', () => { logs: '', success: false, }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -338,7 +330,6 @@ describe('logs command', () => { logs: '', success: false, }), - true, expect.objectContaining({ telemetry: expect.anything(), }) @@ -546,7 +537,6 @@ Last line`; logs: '', success: false, }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -578,7 +568,6 @@ Last line`; logs: '', success: false, }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -602,7 +591,6 @@ Last line`; expect.objectContaining({ error: 'Error retrieving container logs: Docker daemon not running', }), - false, expect.objectContaining({ telemetry: expect.anything(), }) @@ -629,7 +617,6 @@ Last line`; error: 'Error retrieving container logs: permission denied while trying to connect to the Docker daemon socket', }), - false, expect.objectContaining({ telemetry: expect.anything(), }) diff --git a/packages/cli/src/commands/__tests__/restart.test.ts b/packages/cli/src/commands/__tests__/restart.test.ts index ff48dc07..ceba70d8 100644 --- a/packages/cli/src/commands/__tests__/restart.test.ts +++ b/packages/cli/src/commands/__tests__/restart.test.ts @@ -189,7 +189,6 @@ describe('restart command', async () => { expect.objectContaining({ error: expect.stringContaining('not in NEW or FAILED status'), }), - true, expect.objectContaining({ tips: expect.arrayContaining([ 'Only NEW and FAILED tasks can be restarted', @@ -211,7 +210,6 @@ describe('restart command', async () => { expect.objectContaining({ error: expect.stringContaining('Invalid task ID'), }), - true, expect.objectContaining({ telemetry: expect.anything(), }) @@ -230,7 +228,6 @@ describe('restart command', async () => { expect.objectContaining({ error: expect.stringContaining('not found'), }), - true, expect.objectContaining({ telemetry: expect.anything(), }) diff --git a/packages/cli/src/commands/__tests__/task.e2e.test.ts b/packages/cli/src/commands/__tests__/task.e2e.test.ts index 33c550b7..9bd152d4 100644 --- a/packages/cli/src/commands/__tests__/task.e2e.test.ts +++ b/packages/cli/src/commands/__tests__/task.e2e.test.ts @@ -92,7 +92,7 @@ describe('rover task (e2e)', () => { HOME: process.env.HOME, USER: process.env.USER, TMPDIR: process.env.TMPDIR, - ROVER_TELEMETRY_DISABLED: '1', + ROVER_NO_TELEMETRY: '1', }, }); }); @@ -118,7 +118,7 @@ describe('rover task (e2e)', () => { HOME: process.env.HOME, USER: process.env.USER, TMPDIR: process.env.TMPDIR, - ROVER_TELEMETRY_DISABLED: '1', + ROVER_NO_TELEMETRY: '1', }, reject: false, // Don't throw on non-zero exit }); diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index ad0a1c27..8e030674 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -13,6 +13,7 @@ import { } from '../utils/exit.js'; import { CLIJsonOutputWithErrors } from '../types.js'; import { findProjectRoot, Git } from 'rover-common'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; const { prompt } = enquirer; @@ -25,6 +26,10 @@ export const deleteCommand = async ( taskIds: string[], options: { json?: boolean; yes?: boolean } = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); const git = new Git(); @@ -47,7 +52,7 @@ export const deleteCommand = async ( } if (jsonOutput.errors.length > 0) { - await exitWithErrors(jsonOutput, json, { telemetry }); + await exitWithErrors(jsonOutput, { telemetry }); return; } @@ -86,13 +91,13 @@ export const deleteCommand = async ( // Exit early if no valid tasks to delete if (tasksToDelete.length === 0) { jsonOutput.success = false; - await exitWithErrors(jsonOutput, json, { telemetry }); + await exitWithErrors(jsonOutput, { telemetry }); await telemetry?.shutdown(); return; } // Show tasks information and get single confirmation - if (!json) { + if (!isJsonMode()) { showRoverChat(["It's time to cleanup some tasks!"]); console.log( @@ -139,7 +144,7 @@ export const deleteCommand = async ( if (!confirmDeletion) { jsonOutput.errors?.push('Task deletion cancelled'); - await exitWithErrors(jsonOutput, json, { telemetry }); + await exitWithErrors(jsonOutput, { telemetry }); await telemetry?.shutdown(); return; } @@ -193,18 +198,16 @@ export const deleteCommand = async ( await exitWithSuccess( `All tasks (IDs: ${succeededTasks.join(' ')}) deleted successfully`, jsonOutput, - json, { telemetry } ); } else if (someSucceeded) { await exitWithWarn( `Some tasks (IDs: ${succeededTasks.join(' ')}) deleted successfully`, jsonOutput, - json, { telemetry } ); } else { - await exitWithErrors(jsonOutput, json, { telemetry }); + await exitWithErrors(jsonOutput, { telemetry }); } await telemetry?.shutdown(); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d2dbc085..c854f21f 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -14,7 +14,7 @@ import { checkQwen, checkGit, } from '../utils/system.js'; -import { ProjectConfig, UserSettings } from '../lib/config.js'; +import { ProjectConfigManager, UserSettingsManager } from 'rover-schemas'; import { showRoverChat, showTips, TIP_TITLES } from '../utils/display.js'; import { AI_AGENT } from 'rover-common'; import { getTelemetry } from '../lib/telemetry.js'; @@ -150,12 +150,12 @@ export const initCommand = async ( } // Check if already initialized - if (ProjectConfig.exists() && UserSettings.exists()) { + if (ProjectConfigManager.exists() && UserSettingsManager.exists()) { console.log( colors.cyan('\n✓ Rover is already initialized in this directory') ); return; - } else if (!UserSettings.exists()) { + } else if (!UserSettingsManager.exists()) { console.log( colors.green( '\n✓ Rover is initialized in this directory. User settings will be initialized now.' @@ -274,10 +274,10 @@ export const initCommand = async ( try { // Save Project Configuration (rover.json) - let projectConfig: ProjectConfig; + let projectConfig: ProjectConfigManager; - if (ProjectConfig.exists()) { - projectConfig = ProjectConfig.load(); + if (ProjectConfigManager.exists()) { + projectConfig = ProjectConfigManager.load(); // Update with detected values environment.languages.forEach(lang => projectConfig.addLanguage(lang)); environment.packageManagers.forEach(pm => @@ -288,7 +288,7 @@ export const initCommand = async ( ); projectConfig.setAttribution(attribution); } else { - projectConfig = ProjectConfig.create(); + projectConfig = ProjectConfigManager.create(); projectConfig.setAttribution(attribution); // Set detected values environment.languages.forEach(lang => projectConfig.addLanguage(lang)); @@ -301,14 +301,14 @@ export const initCommand = async ( } // Save User Settings (.rover/settings.json) - let userSettings: UserSettings; - if (UserSettings.exists()) { - userSettings = UserSettings.load(); + let userSettings: UserSettingsManager; + if (UserSettingsManager.exists()) { + userSettings = UserSettingsManager.load(); // Update AI agents availableAgents.forEach(agent => userSettings.addAiAgent(agent)); userSettings.setDefaultAiAgent(defaultAIAgent); } else { - userSettings = UserSettings.createDefault(); + userSettings = UserSettingsManager.createDefault(); // Set available AI agents and default availableAgents.forEach(agent => userSettings.addAiAgent(agent)); userSettings.setDefaultAiAgent(defaultAIAgent); diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index 2fdba0e7..50f76dbb 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -16,6 +16,7 @@ import { showTitle, } from 'rover-common'; import { IterationManager } from 'rover-schemas'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; const DEFAULT_FILE_CONTENTS = 'summary.md'; @@ -113,11 +114,15 @@ export const inspectCommand = async ( iterationNumber?: number, options: { json?: boolean; file?: string[]; rawFile?: string[] } = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + // Convert string taskId to number const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { - if (options.json) { + if (isJsonMode()) { const errorOutput = jsonErrorOutput( `Invalid task ID '${taskId}' - must be a number` ); @@ -137,7 +142,7 @@ export const inspectCommand = async ( // Validate mutually exclusive options if (options.file && options.rawFile) { - if (options.json) { + if (isJsonMode()) { const errorOutput = jsonErrorOutput( 'Cannot use both --file and --raw-file options together' ); @@ -183,7 +188,7 @@ export const inspectCommand = async ( if (options.rawFile) { const rawFileContents = iteration.getMarkdownFiles(options.rawFile); - if (options.json) { + if (isJsonMode()) { // Output JSON format with RawFileOutput array const rawFileOutput: RawFileOutput = { success: true, @@ -226,7 +231,7 @@ export const inspectCommand = async ( return; } - if (options.json) { + if (isJsonMode()) { // Output JSON format const jsonOutput: TaskInspectionOutput = { branchName: task.branchName, @@ -343,14 +348,14 @@ export const inspectCommand = async ( await telemetry?.shutdown(); } catch (error) { if (error instanceof TaskNotFoundError) { - if (options.json) { + if (isJsonMode()) { const errorOutput = jsonErrorOutput(error.message, numericTaskId); console.log(JSON.stringify(errorOutput, null, 2)); } else { console.log(colors.red(`✗ ${error.message}`)); } } else { - if (options.json) { + if (isJsonMode()) { const errorOutput = jsonErrorOutput( `Error inspecting task: ${error}`, numericTaskId diff --git a/packages/cli/src/commands/iterate.ts b/packages/cli/src/commands/iterate.ts index e9086873..26738c26 100644 --- a/packages/cli/src/commands/iterate.ts +++ b/packages/cli/src/commands/iterate.ts @@ -19,6 +19,7 @@ import { showRoverChat } from '../utils/display.js'; import { readFromStdin, stdinIsAvailable } from '../utils/stdin.js'; import { CLIJsonOutput } from '../types.js'; import { exitWithError, exitWithSuccess, exitWithWarn } from '../utils/exit.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; const { prompt } = enquirer; @@ -139,6 +140,10 @@ export const iterateCommand = async ( instructions?: string, options: { json?: boolean } = {} ): Promise => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); const json = options.json === true; const result: IterateResult = { @@ -153,7 +158,7 @@ export const iterateCommand = async ( const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { result.error = `Invalid task ID '${taskId}' - must be a number`; - if (options.json) { + if (isJsonMode()) { console.log(JSON.stringify(result, null, 2)); } else { console.log(colors.red(`✗ ${result.error}`)); @@ -172,7 +177,7 @@ export const iterateCommand = async ( const stdinInput = await readFromStdin(); if (stdinInput) { finalInstructions = stdinInput; - if (!options.json) { + if (!isJsonMode()) { showRoverChat( [ "hey human! Let's iterate on this task.", @@ -189,9 +194,9 @@ export const iterateCommand = async ( // If still no instructions and not in JSON mode, prompt user if (!finalInstructions) { - if (json) { + if (isJsonMode()) { result.error = 'Instructions are required in JSON mode'; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); return; } else { showRoverChat( @@ -216,14 +221,14 @@ export const iterateCommand = async ( }); finalInstructions = input; } catch (_err) { - await exitWithWarn('Task deletion cancelled', result, json, { + await exitWithWarn('Task deletion cancelled', result, { telemetry, }); return; } } } else { - if (!json) { + if (!isJsonMode()) { showRoverChat( [ "hey human! Let's iterate on this task.", @@ -250,7 +255,7 @@ export const iterateCommand = async ( try { selectedAiAgent = getUserAIAgent(); } catch (_err) { - if (!json) { + if (!isJsonMode()) { console.log( colors.yellow( '⚠ Could not load user settings, defaulting to Claude' @@ -259,7 +264,7 @@ export const iterateCommand = async ( } } } else { - if (!options.json) { + if (!isJsonMode()) { console.log(colors.gray(`Using agent from task: ${selectedAiAgent}`)); } } @@ -274,7 +279,7 @@ export const iterateCommand = async ( ); result.taskTitle = task.title; - if (!options.json) { + if (!isJsonMode()) { console.log(colors.bold('Task Details')); console.log(colors.gray('├── ID: ') + colors.cyan(task.id.toString())); console.log(colors.gray('├── Task Title: ') + task.title); @@ -291,7 +296,7 @@ export const iterateCommand = async ( ); // Expand task with AI - if (!options.json) { + if (!isJsonMode()) { console.log(''); } @@ -315,7 +320,7 @@ export const iterateCommand = async ( if (spinner) spinner.success('Task iteration expanded!'); } else { if (spinner) spinner.error('Failed to expand task iteration'); - if (!options.json) { + if (!isJsonMode()) { console.log( colors.yellow( '\n⚠ AI expansion failed. Using manual iteration approach.' @@ -342,13 +347,13 @@ export const iterateCommand = async ( }; } - if (!options.json) { + if (!isJsonMode()) { console.log(''); } if (!expandedTask) { result.error = 'Could not create iteration'; - if (options.json) { + if (isJsonMode()) { console.log(JSON.stringify(result, null, 2)); } else { console.log(colors.red('✗ Could not create iteration')); @@ -360,7 +365,7 @@ export const iterateCommand = async ( result.expandedDescription = expandedTask.description; // Skip confirmation and refinement instructions if --json flag is passed - if (!options.json) { + if (!isJsonMode()) { // Display the expanded iteration console.log(colors.bold('Iteration:')); console.log( @@ -374,7 +379,7 @@ export const iterateCommand = async ( launchSync('git', ['rev-parse', '--is-inside-work-tree']); } catch (error) { result.error = 'Not in a git repository'; - if (options.json) { + if (isJsonMode()) { console.log(JSON.stringify(result, null, 2)); } else { console.log(colors.red('✗ Not in a git repository')); @@ -386,7 +391,7 @@ export const iterateCommand = async ( // Ensure workspace exists if (!task.worktreePath || !existsSync(task.worktreePath)) { result.error = 'No workspace found for this task'; - if (options.json) { + if (isJsonMode()) { console.log(JSON.stringify(result, null, 2)); } else { console.log(colors.red('✗ No workspace found for this task')); @@ -433,11 +438,14 @@ export const iterateCommand = async ( // Start sandbox container for task execution const sandbox = await createSandbox(task); - await sandbox.createAndStart(); + const containerId = await sandbox.createAndStart(); + + // Update task metadata with new container ID for this iteration + task.setContainerInfo(containerId, 'running'); result.success = true; - await exitWithSuccess('Iteration started successfully', result, json, { + await exitWithSuccess('Iteration started successfully', result, { tips: [ 'Use ' + colors.cyan('rover list') + ' to check the list of tasks', 'Use ' + @@ -458,7 +466,7 @@ export const iterateCommand = async ( result.error = 'Unknown error creating task iteration'; } - if (options.json) { + if (isJsonMode()) { console.log(JSON.stringify(result, null, 2)); } else { if (error instanceof TaskNotFoundError) { diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index e02a54c1..6c6e6d87 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -5,6 +5,7 @@ import { TaskDescriptionStore, TaskDescriptionSchema } from 'rover-schemas'; import { VERBOSE, showTips, Table, TableColumn } from 'rover-common'; import { IterationStatusManager } from 'rover-schemas'; import { IterationManager } from 'rover-schemas'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; /** * Format duration from start to now or completion @@ -73,6 +74,10 @@ export const listCommand = async ( watching?: boolean; } = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); try { @@ -83,7 +88,7 @@ export const listCommand = async ( } if (tasks.length === 0) { - if (options.json) { + if (isJsonMode()) { console.log(JSON.stringify([])); } else { console.log(colors.yellow('📋 No tasks found')); @@ -102,7 +107,7 @@ export const listCommand = async ( try { task.updateStatusFromIteration(); } catch (err) { - if (!options.json) { + if (!isJsonMode()) { console.log( `\n${colors.yellow(`⚠ Failed to update the status of task ${task.id}`)}` ); @@ -115,7 +120,7 @@ export const listCommand = async ( }); // JSON output mode - if (options.json) { + if (isJsonMode()) { const jsonOutput: Array< TaskDescriptionSchema & { iterationsData: IterationManager[] } > = []; diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts index 6c20b387..0f87ab55 100644 --- a/packages/cli/src/commands/logs.ts +++ b/packages/cli/src/commands/logs.ts @@ -7,6 +7,7 @@ import { getTelemetry } from '../lib/telemetry.js'; import { showTips } from '../utils/display.js'; import { CLIJsonOutput } from '../types.js'; import { exitWithError, exitWithWarn } from '../utils/exit.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; /** * Interface for JSON output @@ -44,6 +45,10 @@ export const logsCommand = async ( iterationNumber?: string, options: { follow?: boolean; json?: boolean } = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + // Init telemetry const telemetry = getTelemetry(); @@ -58,7 +63,7 @@ export const logsCommand = async ( const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { jsonOutput.error = `Invalid task ID '${taskId}' - must be a number`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -72,7 +77,7 @@ export const logsCommand = async ( targetIteration = parseInt(iterationNumber, 10); if (isNaN(targetIteration)) { jsonOutput.error = `Invalid iteration number: '${iterationNumber}'`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } @@ -86,7 +91,6 @@ export const logsCommand = async ( await exitWithWarn( `No iterations found for task '${numericTaskId}'`, jsonOutput, - json, { telemetry } ); return; @@ -99,7 +103,7 @@ export const logsCommand = async ( // Check if specific iteration exists (if requested) if (targetIteration && !availableIterations.includes(targetIteration)) { jsonOutput.error = `Iteration ${targetIteration} not found for task '${numericTaskId}'. Available iterations: ${availableIterations.join(', ')}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -110,14 +114,13 @@ export const logsCommand = async ( await exitWithWarn( `No container found for task '${numericTaskId}'. Logs are only available for recent tasks`, jsonOutput, - json, { telemetry } ); return; } // Display header - if (!json) { + if (!isJsonMode()) { console.log(colors.bold(`Task ${numericTaskId} Logs`)); console.log(colors.gray('├── Title: ') + task.title); console.log( @@ -127,7 +130,7 @@ export const logsCommand = async ( telemetry?.eventLogs(); - if (!json) { + if (!isJsonMode()) { console.log(''); console.log(colors.bold('Execution Log\n')); } @@ -189,12 +192,11 @@ export const logsCommand = async ( await exitWithWarn( 'No logs available for this container. Logs are only available for recent tasks', jsonOutput, - json, { telemetry } ); return; } else { - if (json) { + if (isJsonMode()) { // Store logs jsonOutput.logs = logs; } else { @@ -215,13 +217,12 @@ export const logsCommand = async ( await exitWithWarn( 'No logs available for this container. Logs are only available for recent tasks', jsonOutput, - json, { telemetry } ); return; } else { jsonOutput.error = `Error retrieving container logs: ${dockerError.message}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } @@ -262,10 +263,10 @@ export const logsCommand = async ( } catch (error) { if (error instanceof TaskNotFoundError) { jsonOutput.error = `The task with ID ${numericTaskId} was not found`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); } else { jsonOutput.error = `There was an error reading task logs: ${error}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); } } finally { await telemetry?.shutdown(); diff --git a/packages/cli/src/commands/merge.ts b/packages/cli/src/commands/merge.ts index 32c3135d..3461c89c 100644 --- a/packages/cli/src/commands/merge.ts +++ b/packages/cli/src/commands/merge.ts @@ -5,13 +5,14 @@ import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { getAIAgentTool, type AIAgentTool } from '../lib/agents/index.js'; import { TaskDescriptionManager, TaskNotFoundError } from 'rover-schemas'; -import { UserSettings, ProjectConfig } from '../lib/config.js'; +import { UserSettingsManager, ProjectConfigManager } from 'rover-schemas'; import { AI_AGENT } from 'rover-common'; import { getTelemetry } from '../lib/telemetry.js'; import { Git } from 'rover-common'; import { showRoverChat, showTips } from '../utils/display.js'; import { exitWithError, exitWithSuccess, exitWithWarn } from '../utils/exit.js'; import { CLIJsonOutput } from '../types.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; import { findProjectRoot } from 'rover-common'; const { prompt } = enquirer; @@ -51,7 +52,7 @@ const getTaskIterationSummaries = ( summaries.push(`Iteration ${iteration}: ${summary}`); } } catch (error) { - if (!options.json) { + if (!isJsonMode()) { console.warn( colors.yellow( `Warning: Could not read summary for iteration ${iteration}` @@ -64,7 +65,7 @@ const getTaskIterationSummaries = ( return summaries; } catch (error) { - if (!options.json) { + if (!isJsonMode()) { console.warn( colors.yellow('Warning: Could not retrieve iteration summaries') ); @@ -93,7 +94,7 @@ const generateCommitMessage = async ( ); if (commitMessage == null || commitMessage.length === 0) { - if (!options.json) { + if (!isJsonMode()) { console.warn( colors.yellow('Warning: Could not generate AI commit message') ); @@ -102,7 +103,7 @@ const generateCommitMessage = async ( return commitMessage; } catch (error) { - if (!options.json) { + if (!isJsonMode()) { console.warn( colors.yellow('Warning: Could not generate AI commit message') ); @@ -122,7 +123,7 @@ const resolveMergeConflicts = async ( ): Promise => { let spinner; - if (!json) { + if (!isJsonMode()) { spinner = yoctoSpinner({ text: 'Analyzing merge conflicts...' }).start(); } @@ -208,6 +209,10 @@ export const mergeCommand = async ( taskId: string, options: MergeOptions = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); const git = new Git(); const jsonOutput: TaskMergeOutput = { @@ -218,17 +223,17 @@ export const mergeCommand = async ( const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { jsonOutput.error = `Invalid task ID '${taskId}' - must be a number`; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } if (!git.isGitRepo()) { jsonOutput.error = 'No worktree found for this task'; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } - if (!options.json) { + if (!isJsonMode()) { showRoverChat([ 'We are ready to go', "Let's merge the task changes and ship it!", @@ -243,20 +248,20 @@ export const mergeCommand = async ( // Load config try { - projectConfig = ProjectConfig.load(); + projectConfig = ProjectConfigManager.load(); } catch (err) { - if (!options.json) { + if (!isJsonMode()) { console.log(colors.yellow('⚠ Could not load project settings')); } } // Load user preferences try { - if (UserSettings.exists()) { - const userSettings = UserSettings.load(); + if (UserSettingsManager.exists()) { + const userSettings = UserSettingsManager.load(); selectedAiAgent = userSettings.defaultAiAgent || AI_AGENT.Claude; } else { - if (!options.json) { + if (!isJsonMode()) { console.log( colors.yellow('⚠ User settings not found, defaulting to Claude') ); @@ -266,7 +271,7 @@ export const mergeCommand = async ( } } } catch (error) { - if (!options.json) { + if (!isJsonMode()) { console.log( colors.yellow('⚠ Could not load user settings, defaulting to Claude') ); @@ -284,7 +289,7 @@ export const mergeCommand = async ( jsonOutput.taskTitle = task.title; jsonOutput.branchName = task.branchName; - if (!options.json) { + if (!isJsonMode()) { console.log(colors.bold('Merge Task')); console.log(colors.gray('├── ID: ') + colors.cyan(task.id.toString())); console.log(colors.gray('├── Title: ') + task.title); @@ -295,19 +300,19 @@ export const mergeCommand = async ( if (task.isPushed()) { jsonOutput.error = 'The task is already merged and pushed'; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } if (task.isMerged()) { jsonOutput.error = 'The task is already merged'; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } if (!task.isCompleted()) { jsonOutput.error = 'The task is not completed yet'; - await exitWithError(jsonOutput, options.json, { + await exitWithError(jsonOutput, { tips: [ 'Use ' + colors.cyan(`rover inspect ${numericTaskId}`) + @@ -324,7 +329,7 @@ export const mergeCommand = async ( // Check if worktree exists if (!task.worktreePath || !existsSync(task.worktreePath)) { jsonOutput.error = 'No worktree found for this task'; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -334,7 +339,7 @@ export const mergeCommand = async ( // Check for uncommitted changes in main repo if (git.hasUncommittedChanges()) { jsonOutput.error = `Current branch (${git.getCurrentBranch()}) has uncommitted changes`; - await exitWithError(jsonOutput, options.json, { + await exitWithError(jsonOutput, { tips: ['Please commit or stash your changes before merging'], telemetry, }); @@ -353,7 +358,7 @@ export const mergeCommand = async ( if (!hasWorktreeChanges && !hasUnmerged) { jsonOutput.success = true; - await exitWithSuccess('No changes to merge', jsonOutput, options.json, { + await exitWithSuccess('No changes to merge', jsonOutput, { tips: [ 'The task worktree has no uncommitted changes nor unmerged commits', ], @@ -362,7 +367,7 @@ export const mergeCommand = async ( return; } - if (!options.json) { + if (!isJsonMode()) { // Show what will happen console.log(''); console.log(colors.cyan('The merge process will')); @@ -390,21 +395,21 @@ export const mergeCommand = async ( if (!confirm) { jsonOutput.success = true; // User cancelled, not an error - await exitWithWarn('Task merge cancelled', jsonOutput, options.json, { + await exitWithWarn('Task merge cancelled', jsonOutput, { telemetry, }); return; } } catch (err) { jsonOutput.success = true; // User cancelled, not an error - await exitWithWarn('Task merge cancelled', jsonOutput, options.json, { + await exitWithWarn('Task merge cancelled', jsonOutput, { telemetry, }); return; } } - if (!options.json) { + if (!isJsonMode()) { console.log(''); // breakline } @@ -463,7 +468,7 @@ export const mergeCommand = async ( spinner?.error('Failed to commit changes'); jsonOutput.error = 'Failed to add and commit changes in the workspace'; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } @@ -492,7 +497,7 @@ export const mergeCommand = async ( if (mergeConflicts.length > 0) { if (spinner) spinner.error('Merge conflicts detected'); - if (!options.json) { + if (!isJsonMode()) { // Print conflicts console.log( colors.yellow( @@ -507,7 +512,7 @@ export const mergeCommand = async ( } // Attempt to fix them with an AI - if (!options.json) { + if (!isJsonMode()) { showRoverChat([ 'I noticed some merge conflicts. I will try to solve them', ]); @@ -523,7 +528,7 @@ export const mergeCommand = async ( if (resolutionSuccessful) { jsonOutput.conflictsResolved = true; - if (!options.json) { + if (!isJsonMode()) { showRoverChat([ 'The merge conflicts are fixed. You can check the file content to confirm it.', ]); @@ -550,7 +555,6 @@ export const mergeCommand = async ( await exitWithWarn( 'User rejected AI resolution. Merge aborted', jsonOutput, - options.json, { telemetry } ); return; @@ -565,7 +569,7 @@ export const mergeCommand = async ( jsonOutput.merged = true; task.markMerged(); - if (!options.json) { + if (!isJsonMode()) { console.log( colors.green( '\n✓ Merge conflicts resolved and merge completed' @@ -577,12 +581,12 @@ export const mergeCommand = async ( git.abortMerge(); jsonOutput.error = `Error completing merge after conflict resolution: ${commitError}`; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } else { jsonOutput.error = 'AI failed to resolve merge conflicts'; - if (!options.json) { + if (!isJsonMode()) { console.log( colors.yellow('\n⚠ Merge aborted due to conflicts.') ); @@ -603,7 +607,7 @@ export const mergeCommand = async ( console.log('\nIf you prefer to stop the process:'); console.log(colors.cyan(`└── 1. Run: git merge --abort`)); } - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } else { @@ -617,7 +621,6 @@ export const mergeCommand = async ( await exitWithSuccess( 'Task has been successfully merged into your current branch', jsonOutput, - options.json, { tips: [ 'Run ' + @@ -632,16 +635,16 @@ export const mergeCommand = async ( } catch (error: any) { if (spinner) spinner.error('Merge failed'); jsonOutput.error = `Error during merge: ${error.message}`; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } catch (error) { if (error instanceof TaskNotFoundError) { jsonOutput.error = error.message; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); } else { jsonOutput.error = `Error merging task: ${error}`; - await exitWithError(jsonOutput, options.json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); } } finally { await telemetry?.shutdown(); diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 0d63549f..1ae92edf 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -6,10 +6,11 @@ import { TaskDescriptionManager, TaskNotFoundError } from 'rover-schemas'; import { getTelemetry } from '../lib/telemetry.js'; import { CLIJsonOutput } from '../types.js'; import { exitWithError, exitWithSuccess, exitWithWarn } from '../utils/exit.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; import { showRoverChat, TIP_TITLES } from '../utils/display.js'; import { statusColor } from '../utils/task-status.js'; import { Git } from 'rover-common'; -import { ProjectConfig } from '../lib/config.js'; +import { ProjectConfigManager } from 'rover-schemas'; const { prompt } = enquirer; @@ -56,6 +57,10 @@ interface PushResult extends CLIJsonOutput { * Push command implementation */ export const pushCommand = async (taskId: string, options: PushOptions) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); const json = options.json === true; const git = new Git(); @@ -73,7 +78,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { result.error = `Invalid task ID '${taskId}' - must be a number`; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); return; } @@ -84,9 +89,9 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { // Load config try { - projectConfig = ProjectConfig.load(); + projectConfig = ProjectConfigManager.load(); } catch (err) { - if (!options.json) { + if (!isJsonMode()) { console.log(colors.yellow('⚠ Could not load project settings')); } } @@ -100,11 +105,11 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { if (!task.worktreePath || !existsSync(task.worktreePath)) { result.error = 'Task workspace not found'; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); return; } - if (!json) { + if (!isJsonMode()) { showRoverChat(["We are good to go. Let's push the changes."]); const colorFunc = statusColor(task.status); @@ -133,7 +138,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { if (!unpushedCommits) { result.success = true; - await exitWithWarn('No changes to push', result, json, { telemetry }); + await exitWithWarn('No changes to push', result, { telemetry }); return; } } catch { @@ -147,7 +152,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { let commitMessage = options.message; if (!commitMessage) { const defaultMessage = `Task ${numericTaskId}: ${task.title}`; - if (options.json) { + if (isJsonMode()) { commitMessage = defaultMessage; } else { try { @@ -188,7 +193,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { } catch (error: any) { result.error = `Failed to commit changes: ${error.message}`; commitSpinner?.error('Failed to commit changes'); - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); return; } } @@ -226,19 +231,19 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { result.pushed = true; pushSpinner?.success('Branch pushed successfully'); task.markPushed(); // Set status to PUSHED - if (!options.json) { + if (!isJsonMode()) { console.log(colors.green(`✓ Branch pushed successfully`)); } } catch (retryError: any) { pushSpinner?.error('Failed to push branch'); result.error = `Failed to push branch: ${retryError.message}`; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); return; } } else { pushSpinner?.error('Failed to push branch'); result.error = `Failed to push branch: ${error.message}`; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); return; } } @@ -257,7 +262,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { // result.pullRequest = { // created: false // }; - // if (!options.json) { + // if (!isJsonMode()) { // console.log(colors.yellow('\n⚠ GitHub CLI (gh) not found')); // console.log(colors.gray(' Install it from: https://cli.github.com')); // console.log(colors.gray(' Then you can create a PR with: ') + @@ -279,7 +284,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { // prSpinner?.success('Pull request created'); - // if (!options.json) { + // if (!isJsonMode()) { // console.log(colors.green('\n✓ Pull Request created: ') + colors.cyan(result.pullRequest.url || 'Not available')); // } // } catch (error: any) { @@ -300,7 +305,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { // // Couldn't get PR URL // } - // if (!options.json) { + // if (!isJsonMode()) { // console.log(colors.yellow('⚠ A pull request already exists for this branch')); // if (result.pullRequest.url) { // console.log(colors.gray(' Existing PR: ') + colors.cyan(result.pullRequest.url)); @@ -310,7 +315,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { // result.pullRequest = { // created: false // }; - // if (!options.json) { + // if (!isJsonMode()) { // console.error(colors.red('Error:'), error.message); // console.log(colors.gray('\n You can manually create a PR at:')); // console.log(colors.cyan(` https://github.com/${repoInfo.owner}/${repoInfo.repo}/pull/new/${task.branchName}`)); @@ -345,7 +350,7 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { ); } - await exitWithSuccess('Push completed successfully!', result, json, { + await exitWithSuccess('Push completed successfully!', result, { tips, tipsConfig: { title: TIP_TITLES.NEXT_STEPS, @@ -355,10 +360,10 @@ export const pushCommand = async (taskId: string, options: PushOptions) => { } catch (error: any) { if (error instanceof TaskNotFoundError) { result.error = `The task with ID ${numericTaskId} was not found`; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); } else { result.error = `There was an error deleting the task: ${error}`; - await exitWithError(result, json, { telemetry }); + await exitWithError(result, { telemetry }); } } finally { await telemetry?.shutdown(); diff --git a/packages/cli/src/commands/restart.ts b/packages/cli/src/commands/restart.ts index de0ccb6b..53c0194d 100644 --- a/packages/cli/src/commands/restart.ts +++ b/packages/cli/src/commands/restart.ts @@ -5,11 +5,12 @@ import { generateBranchName } from '../utils/branch-name.js'; import { TaskDescriptionManager, TaskNotFoundError } from 'rover-schemas'; import { exitWithError, exitWithSuccess } from '../utils/exit.js'; import { createSandbox } from '../lib/sandbox/index.js'; -import { UserSettings } from '../lib/config.js'; +import { UserSettingsManager } from 'rover-schemas'; import { AI_AGENT, Git } from 'rover-common'; import { CLIJsonOutput } from '../types.js'; import { IterationManager } from 'rover-schemas'; import { getTelemetry } from '../lib/telemetry.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; import yoctoSpinner from 'yocto-spinner'; import { copyEnvironmentFiles } from '../utils/env-files.js'; import { findProjectRoot } from 'rover-common'; @@ -32,6 +33,10 @@ export const restartCommand = async ( taskId: string, options: { json?: boolean } = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); const json = options.json === true; @@ -43,7 +48,7 @@ export const restartCommand = async ( const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { jsonOutput.error = `Invalid task ID '${taskId}' - must be a number`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -54,7 +59,7 @@ export const restartCommand = async ( // Check if task is in NEW or FAILED status if (!task.isNew() && !task.isFailed()) { jsonOutput.error = `Task ${taskId} is not in NEW or FAILED status (current: ${task.status})`; - await exitWithError(jsonOutput, json, { + await exitWithError(jsonOutput, { tips: [ 'Only NEW and FAILED tasks can be restarted', 'Use ' + @@ -74,12 +79,12 @@ export const restartCommand = async ( let selectedAiAgent = AI_AGENT.Claude; // default try { - if (UserSettings.exists()) { - const userSettings = UserSettings.load(); + if (UserSettingsManager.exists()) { + const userSettings = UserSettingsManager.load(); selectedAiAgent = userSettings.defaultAiAgent || AI_AGENT.Claude; } } catch (error) { - if (!json) { + if (!isJsonMode()) { console.log( colors.yellow('⚠ Could not load user settings, defaulting to Claude') ); @@ -139,7 +144,7 @@ export const restartCommand = async ( ); } - if (!json) { + if (!isJsonMode()) { console.log(colors.bold('Restarting Task')); console.log(colors.gray('├── ID: ') + colors.cyan(task.id.toString())); console.log(colors.gray('├── Title: ') + task.title); @@ -160,7 +165,10 @@ export const restartCommand = async ( // Start sandbox container for task execution try { const sandbox = await createSandbox(task); - await sandbox.createAndStart(); + const containerId = await sandbox.createAndStart(); + + // Update task metadata with new container ID + task.setContainerInfo(containerId, 'running'); } catch (error) { // If sandbox execution fails, reset task back to NEW status task.resetToNew(); @@ -178,7 +186,7 @@ export const restartCommand = async ( restartedAt: restartedAt, }; - await exitWithSuccess('Task restarted succesfully!', jsonOutput, json, { + await exitWithSuccess('Task restarted succesfully!', jsonOutput, { tips: [ 'Use ' + colors.cyan('rover list') + ' to check the list of tasks', 'Use ' + @@ -195,11 +203,11 @@ export const restartCommand = async ( } catch (error) { if (error instanceof TaskNotFoundError) { jsonOutput.error = `The task with ID ${numericTaskId} was not found`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } else { jsonOutput.error = `There was an error restarting the task: ${error}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } finally { diff --git a/packages/cli/src/commands/shell.ts b/packages/cli/src/commands/shell.ts index 3c9c44f6..4103cd28 100644 --- a/packages/cli/src/commands/shell.ts +++ b/packages/cli/src/commands/shell.ts @@ -7,6 +7,7 @@ import { TaskDescriptionManager, TaskNotFoundError } from 'rover-schemas'; import { getTelemetry } from '../lib/telemetry.js'; import { CLIJsonOutput } from '../types.js'; import { exitWithError, exitWithSuccess, exitWithWarn } from '../utils/exit.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; import { createSandbox, getAvailableSandboxBackend, @@ -31,7 +32,7 @@ export const shellCommand = async ( const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { jsonOutput.error = `Invalid task ID '${taskId}' - must be a number`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -49,7 +50,7 @@ export const shellCommand = async ( // Check if worktree exists if (!task.worktreePath || !existsSync(task.worktreePath)) { jsonOutput.error = `No worktree found for this task`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -61,7 +62,7 @@ export const shellCommand = async ( if (!availableBackend) { jsonOutput.error = `Neither Docker nor Podman are available. Please install Docker or Podman.`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } @@ -93,7 +94,7 @@ export const shellCommand = async ( } catch (error) { spinner.error('Failed to start container shell'); jsonOutput.error = 'Failed to start container: ' + error; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } else { @@ -166,7 +167,7 @@ export const shellCommand = async ( } catch (error) { spinner.error(`Failed to start shell ${shell}`); jsonOutput.error = 'Failed to start shell: ' + error; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } @@ -174,14 +175,13 @@ export const shellCommand = async ( if (shellProcess) { // Handle process completion if (shellProcess.exitCode === 0) { - await exitWithSuccess('Shell session ended', jsonOutput, json, { + await exitWithSuccess('Shell session ended', jsonOutput, { telemetry, }); } else { await exitWithWarn( `Shell session ended with code ${shellProcess.exitCode}`, jsonOutput, - json, { telemetry } ); } @@ -189,10 +189,10 @@ export const shellCommand = async ( } catch (error) { if (error instanceof TaskNotFoundError) { jsonOutput.error = `The task with ID ${numericTaskId} was not found`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); } else { jsonOutput.error = `There was an error starting the shell: ${error}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); } } finally { await telemetry?.shutdown(); diff --git a/packages/cli/src/commands/stop.ts b/packages/cli/src/commands/stop.ts index 2f4db765..ebd609a6 100644 --- a/packages/cli/src/commands/stop.ts +++ b/packages/cli/src/commands/stop.ts @@ -7,6 +7,7 @@ import { findProjectRoot, launch, ProcessManager } from 'rover-common'; import { exitWithError, exitWithSuccess } from '../utils/exit.js'; import { CLIJsonOutput } from '../types.js'; import { getTelemetry } from '../lib/telemetry.js'; +import { isJsonMode, setJsonMode } from '../lib/global-state.js'; /** * Interface for JSON output @@ -30,6 +31,10 @@ export const stopCommand = async ( removeGitWorktreeAndBranch?: boolean; } = {} ) => { + if (options.json !== undefined) { + setJsonMode(options.json); + } + const telemetry = getTelemetry(); // Track stop task event @@ -49,7 +54,7 @@ export const stopCommand = async ( const numericTaskId = parseInt(taskId, 10); if (isNaN(numericTaskId)) { jsonOutput.error = `Invalid task ID '${taskId}' - must be a number`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -95,7 +100,7 @@ export const stopCommand = async ( // Remove worktree from git's tracking await launch('git', ['worktree', 'prune'], { stdio: 'pipe' }); } catch (manualError) { - if (!json) { + if (!isJsonMode()) { console.warn( colors.yellow('Warning: Could not remove workspace directory') ); @@ -154,7 +159,7 @@ export const stopCommand = async ( status: task.status, stoppedAt: new Date().toISOString(), }; - await exitWithSuccess('Task stopped successfully!', jsonOutput, json, { + await exitWithSuccess('Task stopped successfully!', jsonOutput, { tips: [ 'Use ' + colors.cyan(`rover logs ${task.id}`) + ' to check the logs', 'Use ' + @@ -169,11 +174,11 @@ export const stopCommand = async ( } catch (error) { if (error instanceof TaskNotFoundError) { jsonOutput.error = `The task with ID ${numericTaskId} was not found`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } else { jsonOutput.error = `There was an error stopping the task: ${error}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } finally { diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 79a58f92..b391b39e 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -3,11 +3,11 @@ import colors from 'ansi-colors'; import { existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { getNextTaskId } from '../utils/task-id.js'; -import { homedir } from 'node:os'; +import { homedir, platform } from 'node:os'; import { getAIAgentTool, getUserAIAgent } from '../lib/agents/index.js'; import { TaskDescriptionManager } from 'rover-schemas'; import { createSandbox } from '../lib/sandbox/index.js'; -import { AI_AGENT } from 'rover-common'; +import { AI_AGENT, launchSync } from 'rover-common'; import { IterationManager } from 'rover-schemas'; import { generateBranchName } from '../utils/branch-name.js'; import { @@ -25,6 +25,7 @@ import { GitHub, GitHubError } from '../lib/github.js'; import { copyEnvironmentFiles } from '../utils/env-files.js'; import { initWorkflowStore } from '../lib/workflow.js'; import { WorkflowManager } from 'rover-schemas'; +import { setJsonMode, isJsonMode } from '../lib/global-state.js'; const { prompt } = enquirer; @@ -67,7 +68,6 @@ const validations = (selectedAiAgent?: string): validationResult => { } } else if (selectedAiAgent === 'cursor') { const cursorConfig = join(homedir(), '.cursor', 'cli-config.json'); - const cursorCreds = join(homedir(), '.config', 'cursor', 'auth.json'); if (!existsSync(cursorConfig)) { return { @@ -75,15 +75,6 @@ const validations = (selectedAiAgent?: string): validationResult => { tips: ['Run ' + colors.cyan('cursor-agent') + ' first to configure it'], }; } - - if (!existsSync(cursorCreds)) { - return { - error: 'Cursor credentials not found', - tips: [ - 'Run ' + colors.cyan('cursor-agent') + ' first to set up credentials', - ], - }; - } } else if (selectedAiAgent === 'gemini') { // Check Gemini credentials if needed const geminiFile = join(homedir(), '.gemini', 'settings.json'); @@ -210,6 +201,11 @@ export const taskCommand = async ( const { yes, json, fromGithub, debug, sourceBranch, targetBranch, agent } = options; + // Set global JSON mode for tests and backwards compatibility + if (json !== undefined) { + setJsonMode(json); + } + const workflowName = options.workflow || DEFAULT_WORKFLOW; const jsonOutput: TaskTaskOutput = { @@ -220,7 +216,7 @@ export const taskCommand = async ( const roverPath = join(findProjectRoot(), '.rover'); if (!existsSync(roverPath)) { jsonOutput.error = 'Rover is not initialized in this directory'; - await exitWithError(jsonOutput, json, { + await exitWithError(jsonOutput, { tips: ['Run ' + colors.cyan('rover init') + ' first'], telemetry, }); @@ -244,7 +240,7 @@ export const taskCommand = async ( selectedAiAgent = AI_AGENT.Qwen; } else { jsonOutput.error = `Invalid agent: ${agent}. Valid options are: ${Object.values(AI_AGENT).join(', ')}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } else { @@ -264,7 +260,7 @@ export const taskCommand = async ( if (valid != null) { jsonOutput.error = valid.error; - await exitWithError(jsonOutput, json, { + await exitWithError(jsonOutput, { tips: valid.tips, telemetry, }); @@ -282,18 +278,18 @@ export const taskCommand = async ( workflow = loadedWorkflow; } else { jsonOutput.error = `Could no load the '${workflowName}' workflow`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } catch (err) { jsonOutput.error = `There was an error loading the '${workflowName}' workflow: ${err}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } if (workflow == null) { jsonOutput.error = `The workflow ${workflow} does not exist`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -318,7 +314,7 @@ export const taskCommand = async ( // Validate specified branch exists if (!git.branchExists(sourceBranch)) { jsonOutput.error = `Branch '${sourceBranch}' does not exist`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } else { @@ -412,7 +408,7 @@ export const taskCommand = async ( if (!issueData.body || issueData.body.length == 0) { jsonOutput.error = 'The GitHub issue description is empty. Add more details to the issue so the Agent can complete it successfully.'; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -465,14 +461,14 @@ export const taskCommand = async ( jsonOutput.error = 'Failed to fetch the workflow inputs from issue'; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } } } else { jsonOutput.error = 'Failed to fetch issue from GitHub'; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } catch (err) { @@ -482,7 +478,7 @@ export const taskCommand = async ( jsonOutput.error = `Failed to fetch issue from GitHub: ${err}`; } - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } else { @@ -545,7 +541,7 @@ export const taskCommand = async ( } } catch (err) { jsonOutput.error = 'Task creation cancelled'; - await exitWithWarn('Task creation cancelled', jsonOutput, json, { + await exitWithWarn('Task creation cancelled', jsonOutput, { exitCode: 1, telemetry, }); @@ -563,7 +559,7 @@ export const taskCommand = async ( if (missing.length > 0) { jsonOutput.error = `The workflow requires the following missing properties: ${missing.join(', ')}`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } } @@ -584,10 +580,20 @@ export const taskCommand = async ( findProjectRoot() ); - processManager?.completeLastItem(); + if (!expandedTask) { + jsonOutput.error = `Failed to expand task description using ${selectedAiAgent}`; + await exitWithError(jsonOutput, { + tips: ['Check your agent configuration and try again'], + telemetry, + }); + processManager?.failLastItem(); + return; + } else { + processManager?.completeLastItem(); + } - inputsData.set('description', expandedTask!.description); - inputsData.set('title', expandedTask!.title); + inputsData.set('description', expandedTask.description); + inputsData.set('title', expandedTask.title); processManager?.addItem('Create the task workspace'); @@ -630,7 +636,7 @@ export const taskCommand = async ( copyEnvironmentFiles(findProjectRoot(), worktreePath); } catch (error) { jsonOutput.error = 'Error creating git workspace: ' + error; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } @@ -710,7 +716,7 @@ export const taskCommand = async ( jsonOutput.status = task.status; jsonOutput.error = "Task was created, but reset to 'New' due to an error running the container"; - await exitWithWarn(jsonOutput.error, jsonOutput, json, { + await exitWithWarn(jsonOutput.error, jsonOutput, { exitCode: 1, tips: [ 'Use ' + colors.cyan(`rover restart ${taskId}`) + ' to retry it', @@ -722,7 +728,7 @@ export const taskCommand = async ( jsonOutput.success = true; - await exitWithSuccess('Task was created successfully', jsonOutput, json, { + await exitWithSuccess('Task was created successfully', jsonOutput, { tips: [ 'Use ' + colors.cyan('rover list') + ' to check the list of tasks', 'Use ' + @@ -733,7 +739,7 @@ export const taskCommand = async ( }); } else { jsonOutput.error = `Could not determine the description. Please, provide it.`; - await exitWithError(jsonOutput, json, { telemetry }); + await exitWithError(jsonOutput, { telemetry }); return; } diff --git a/packages/cli/src/commands/workflows/inspect.ts b/packages/cli/src/commands/workflows/inspect.ts index 412e61cf..c383218a 100644 --- a/packages/cli/src/commands/workflows/inspect.ts +++ b/packages/cli/src/commands/workflows/inspect.ts @@ -11,6 +11,7 @@ import { } from 'rover-common'; import { readFileSync } from 'node:fs'; import { getTelemetry } from '../../lib/telemetry.js'; +import { isJsonMode, setJsonMode } from '../../lib/global-state.js'; interface InspectWorkflowCommandOptions { // Output formats @@ -29,6 +30,9 @@ export const inspectWorkflowCommand = async ( options: InspectWorkflowCommandOptions ) => { const telemetry = getTelemetry(); + if (options.json !== undefined) { + setJsonMode(options.json); + } try { // Track inspect workflow event @@ -39,7 +43,7 @@ export const inspectWorkflowCommand = async ( const workflow = workflowStore.getWorkflow(workflowName); if (!workflow) { - if (options.json) { + if (isJsonMode()) { console.log( JSON.stringify( { @@ -73,7 +77,7 @@ export const inspectWorkflowCommand = async ( } // Handle --json flag: output workflow as JSON - if (options.json) { + if (isJsonMode()) { console.log( JSON.stringify( { @@ -184,7 +188,7 @@ export const inspectWorkflowCommand = async ( showDiagram(diagramSteps, { addLineBreak: false }); } } catch (error) { - if (options.json) { + if (isJsonMode()) { console.log( JSON.stringify( { diff --git a/packages/cli/src/commands/workflows/list.ts b/packages/cli/src/commands/workflows/list.ts index ed16cd78..bcd5c03d 100644 --- a/packages/cli/src/commands/workflows/list.ts +++ b/packages/cli/src/commands/workflows/list.ts @@ -8,6 +8,7 @@ import { CLIJsonOutput } from '../../types.js'; import { exitWithError, exitWithSuccess } from '../../utils/exit.js'; import { Workflow } from 'rover-schemas'; import { getTelemetry } from '../../lib/telemetry.js'; +import { isJsonMode, setJsonMode } from '../../lib/global-state.js'; interface ListWorkflowsCommandOptions { // Output format @@ -40,6 +41,10 @@ export const listWorkflowsCommand = async ( options: ListWorkflowsCommandOptions ) => { const telemetry = getTelemetry(); + if (options.json !== undefined) { + setJsonMode(options.json); + } + const workflowStore = initWorkflowStore(); const output: ListWorkflowsOutput = { success: false, @@ -49,14 +54,14 @@ export const listWorkflowsCommand = async ( try { // Track list workflows event telemetry?.eventListWorkflows(); - if (options.json) { + if (isJsonMode()) { // For the JSON, add some extra information. output.success = true; output.workflows = workflowStore .getAllWorkflows() .map(wf => wf.toObject()); - await exitWithSuccess('', output, options.json, { telemetry }); + await exitWithSuccess('', output, { telemetry }); } else { // Define table columns const columns: TableColumn[] = [ @@ -106,7 +111,7 @@ export const listWorkflowsCommand = async ( } } catch (error) { output.error = 'Error loading the workflows.'; - await exitWithError(output, options.json, { telemetry }); + await exitWithError(output, { telemetry }); } finally { await telemetry?.shutdown(); } diff --git a/packages/cli/src/lib/agents/claude.ts b/packages/cli/src/lib/agents/claude.ts index 7181cec6..b922628f 100644 --- a/packages/cli/src/lib/agents/claude.ts +++ b/packages/cli/src/lib/agents/claude.ts @@ -7,6 +7,7 @@ import { } from 'rover-common'; import { AIAgentTool, + findKeychainCredentials, InvokeAIAgentError, MissingAIAgentError, } from './index.js'; @@ -17,15 +18,6 @@ import { join } from 'node:path'; import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; import type { WorkflowInput } from 'rover-schemas'; -const findKeychainCredentials = (key: string): string => { - const result = launchSync( - 'security', - ['find-generic-password', '-s', key, '-w'], - { mightLogSensitiveInformation: true } - ); - return result.stdout?.toString() || ''; -}; - // Environment variables reference: // - https://docs.claude.com/en/docs/claude-code/settings.md // - https://docs.claude.com/en/docs/claude-code/google-vertex-ai.md diff --git a/packages/cli/src/lib/agents/cursor.ts b/packages/cli/src/lib/agents/cursor.ts index 3aa24e20..fe3242ec 100644 --- a/packages/cli/src/lib/agents/cursor.ts +++ b/packages/cli/src/lib/agents/cursor.ts @@ -1,13 +1,14 @@ import { launch, launchSync } from 'rover-common'; import { AIAgentTool, + findKeychainCredentials, InvokeAIAgentError, MissingAIAgentError, } from './index.js'; import { PromptBuilder, IPromptTask } from '../prompts/index.js'; import { parseJsonResponse } from '../../utils/json-parser.js'; -import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir, platform } from 'node:os'; import { join } from 'node:path'; import { fileSync } from 'tmp'; import type { WorkflowInput } from 'rover-schemas'; @@ -19,6 +20,9 @@ const CURSOR_ENV_VARS = [ 'CURSOR_API_KEY', ]; +// macOS Keychain items for Cursor +const CURSOR_KEYCHAIN_ITEMS = ['cursor-access-token', 'cursor-refresh-token']; + class CursorAI implements AIAgentTool { // constants public AGENT_BIN = 'cursor-agent'; @@ -33,7 +37,7 @@ class CursorAI implements AIAgentTool { } async invoke(prompt: string, json: boolean = false): Promise { - const cursorArgs = ['agent', '--print', prompt]; + const cursorArgs = ['agent', '--print']; if (json) { cursorArgs.push('--output-format'); cursorArgs.push('json'); @@ -177,12 +181,37 @@ You MUST output a valid JSON string as an output. Just output the JSON string an dockerMounts.push(`-v`, `${cursorDirectory}:/.cursor:Z,ro`); } - const cursorCredentialsDirectory = join(homedir(), '.config', 'cursor'); - if (existsSync(cursorCredentialsDirectory)) { - dockerMounts.push( - `-v`, - `${cursorCredentialsDirectory}:/.config/cursor:Z,ro` - ); + const cursorAuthDirectory = join(homedir(), '.config', 'cursor'); + if (existsSync(cursorAuthDirectory)) { + dockerMounts.push(`-v`, `${cursorAuthDirectory}:/.config/cursor:Z,ro`); + } else if (platform() === 'darwin') { + // On macOS, if .cursor directory doesn't exist but keychain has credentials, + // create a temporary directory with credentials from keychain + const accessToken = findKeychainCredentials('cursor-access-token'); + const refreshToken = findKeychainCredentials('cursor-refresh-token'); + + if (accessToken || refreshToken) { + const tmpDir = fileSync({ + mode: 0o600, + prefix: 'cursor-', + postfix: '', + }); + const config: any = {}; + + if (accessToken) { + config.accessToken = accessToken; + } + if (refreshToken) { + config.refreshToken = refreshToken; + } + + // Write the config file + const configPath = tmpDir.name; + writeFileSync(configPath, JSON.stringify(config)); + + // Mount the temporary config file + dockerMounts.push(`-v`, `${configPath}:/.config/cursor/auth.json:Z,ro`); + } } return dockerMounts; @@ -191,12 +220,26 @@ You MUST output a valid JSON string as an output. Just output the JSON string an getEnvironmentVariables(): string[] { const envVars: string[] = []; + // Add standard environment variables for (const key of CURSOR_ENV_VARS) { if (process.env[key] !== undefined) { envVars.push('-e', key); } } + // On macOS, extract credentials from Keychain and make them available + if (platform() === 'darwin') { + for (const keychainItem of CURSOR_KEYCHAIN_ITEMS) { + const value = findKeychainCredentials(keychainItem); + if (value) { + // Convert keychain item name to environment variable name + // e.g., cursor-access-token -> CURSOR_ACCESS_TOKEN + const envVarName = keychainItem.toUpperCase().replace(/-/g, '_'); + envVars.push('-e', `${envVarName}=${value}`); + } + } + } + return envVars; } } diff --git a/packages/cli/src/lib/agents/index.ts b/packages/cli/src/lib/agents/index.ts index 570a1aae..533e303f 100644 --- a/packages/cli/src/lib/agents/index.ts +++ b/packages/cli/src/lib/agents/index.ts @@ -4,10 +4,19 @@ import CursorAI from './cursor.js'; import GeminiAI from './gemini.js'; import QwenAI from './qwen.js'; import type { IPromptTask } from '../prompts/index.js'; -import { UserSettings } from '../config.js'; -import { AI_AGENT } from 'rover-common'; +import { UserSettingsManager } from 'rover-schemas'; +import { AI_AGENT, launchSync } from 'rover-common'; import type { WorkflowInput } from 'rover-schemas'; +export const findKeychainCredentials = (key: string): string => { + const result = launchSync( + 'security', + ['find-generic-password', '-s', key, '-w'], + { mightLogSensitiveInformation: true } + ); + return result.stdout?.toString() || ''; +}; + export interface AIAgentTool { // Invoke the CLI tool using the SDK / direct mode with the given prompt invoke(prompt: string, json: boolean): Promise; @@ -106,8 +115,8 @@ export const getAIAgentTool = (agent: string): AIAgentTool => { */ export const getUserAIAgent = (): AI_AGENT => { try { - if (UserSettings.exists()) { - const userSettings = UserSettings.load(); + if (UserSettingsManager.exists()) { + const userSettings = UserSettingsManager.load(); return userSettings.defaultAiAgent || AI_AGENT.Claude; } else { return AI_AGENT.Claude; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts deleted file mode 100644 index b0ade14b..00000000 --- a/packages/cli/src/lib/config.ts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * Define the project, user configuration files and constants - * related to those. - */ -import { join, dirname, resolve } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { FileNotFoundError, InvalidFormatError } from '../errors.js'; -import { AI_AGENT, findProjectRoot, Git } from 'rover-common'; - -// Supported languages -export enum LANGUAGE { - Javascript = 'javascript', - TypeScript = 'typescript', - PHP = 'php', - Rust = 'rust', - Go = 'go', - Python = 'python', - Ruby = 'ruby', -} - -export interface MCP { - name: string; - commandOrUrl: string; - transport: string; - envs?: string[]; - headers?: string[]; -} - -export enum PACKAGE_MANAGER { - PNPM = 'pnpm', - NPM = 'npm', - Yarn = 'yarn', - Composer = 'composer', - Cargo = 'cargo', - Gomod = 'gomod', - PIP = 'pip', - Poetry = 'poetry', - UV = 'uv', - Rubygems = 'rubygems', -} - -export enum TASK_MANAGER { - Just = 'just', - Make = 'make', - Task = 'task', -} - -// Schema version for migrations -export const CURRENT_PROJECT_SCHEMA_VERSION = '1.2'; - -export interface ProjectConfigSchema { - // Common values - version: string; - - // Environment - languages: LANGUAGE[]; - packageManagers: PACKAGE_MANAGER[]; - taskManagers: TASK_MANAGER[]; - - // Attribution - attribution: boolean; - - // MCPs - mcps: MCP[]; - - // Custom environment variables - envs?: string[]; - envsFile?: string; -} - -const PROJECT_CONFIG_FILE = 'rover.json'; - -/** - * Project-wide configuration. Useful to add context and constraints. We - * expect users to commit this file to the repo. - */ -export class ProjectConfig { - constructor(private data: ProjectConfigSchema) {} - - /** - * Load an existing configuration from disk - */ - static load(): ProjectConfig { - const projectRoot = findProjectRoot(); - const filePath = join(projectRoot, PROJECT_CONFIG_FILE); - - try { - const rawData = readFileSync(filePath, 'utf8'); - const parsedData = JSON.parse(rawData); - - // Migrate if necessary - const migratedData = ProjectConfig.migrate(parsedData); - - const instance = new ProjectConfig(migratedData); - - // If migration occurred, save the updated data - if (migratedData.version !== parsedData.version) { - instance.save(); - } - - return instance; - } catch (error) { - if (error instanceof SyntaxError) { - throw new InvalidFormatError(filePath); - } else { - throw new Error('Failed to load the project configuration.'); - } - } - } - - /** - * Create a new project configuration with defaults - */ - static create(): ProjectConfig { - const schema: ProjectConfigSchema = { - version: CURRENT_PROJECT_SCHEMA_VERSION, - languages: [], - mcps: [], - packageManagers: [], - taskManagers: [], - attribution: true, - }; - - const instance = new ProjectConfig(schema); - instance.save(); - return instance; - } - - /** - * Check if a project configuration exists - */ - static exists(): boolean { - const projectRoot = findProjectRoot(); - const filePath = join(projectRoot, PROJECT_CONFIG_FILE); - return existsSync(filePath); - } - - /** - * Migrate old configuration to current schema version - */ - private static migrate(data: any): ProjectConfigSchema { - // If already current version, return as-is - if (data.version === CURRENT_PROJECT_SCHEMA_VERSION) { - return data as ProjectConfigSchema; - } - - // For now, just ensure all required fields exist - const migrated: ProjectConfigSchema = { - version: CURRENT_PROJECT_SCHEMA_VERSION, - languages: data.languages || [], - mcps: data.mcps || [], - packageManagers: data.packageManagers || [], - taskManagers: data.taskManagers || [], - attribution: data.attribution !== undefined ? data.attribution : true, - ...(data.envs !== undefined ? { envs: data.envs } : {}), - ...(data.envsFile !== undefined ? { envsFile: data.envsFile } : {}), - }; - - return migrated; - } - - /** - * Save current configuration to disk - */ - save(): void { - const projectRoot = findProjectRoot(); - const filePath = join(projectRoot, PROJECT_CONFIG_FILE); - try { - const json = JSON.stringify(this.data, null, 2); - writeFileSync(filePath, json, 'utf8'); - } catch (error) { - throw new Error(`Failed to save project configuration: ${error}`); - } - } - - /** - * Reload configuration from disk - */ - reload(): void { - const reloaded = ProjectConfig.load(); - this.data = reloaded.data; - } - - // Data Access (Getters) - get version(): string { - return this.data.version; - } - get languages(): LANGUAGE[] { - return this.data.languages; - } - get mcps(): MCP[] { - return this.data.mcps; - } - get packageManagers(): PACKAGE_MANAGER[] { - return this.data.packageManagers; - } - get taskManagers(): TASK_MANAGER[] { - return this.data.taskManagers; - } - get attribution(): boolean { - return this.data.attribution; - } - get envs(): string[] | undefined { - return this.data.envs; - } - get envsFile(): string | undefined { - return this.data.envsFile; - } - - // Data Modification (Setters) - addLanguage(language: LANGUAGE): void { - if (!this.data.languages.includes(language)) { - this.data.languages.push(language); - this.save(); - } - } - - removeLanguage(language: LANGUAGE): void { - const index = this.data.languages.indexOf(language); - if (index > -1) { - this.data.languages.splice(index, 1); - this.save(); - } - } - - addMCP(mcp: MCP): void { - if (!this.data.mcps.some(m => m.name === mcp.name)) { - this.data.mcps.push(mcp); - this.save(); - } - } - - removeMCP(mcp: MCP): void { - const index = this.data.mcps.findIndex(m => m.name === mcp.name); - if (index > -1) { - this.data.mcps.splice(index, 1); - this.save(); - } - } - - addPackageManager(packageManager: PACKAGE_MANAGER): void { - if (!this.data.packageManagers.includes(packageManager)) { - this.data.packageManagers.push(packageManager); - this.save(); - } - } - - removePackageManager(packageManager: PACKAGE_MANAGER): void { - const index = this.data.packageManagers.indexOf(packageManager); - if (index > -1) { - this.data.packageManagers.splice(index, 1); - this.save(); - } - } - - addTaskManager(taskManager: TASK_MANAGER): void { - if (!this.data.taskManagers.includes(taskManager)) { - this.data.taskManagers.push(taskManager); - this.save(); - } - } - - removeTaskManager(taskManager: TASK_MANAGER): void { - const index = this.data.taskManagers.indexOf(taskManager); - if (index > -1) { - this.data.taskManagers.splice(index, 1); - this.save(); - } - } - - setAttribution(value: boolean): void { - this.data.attribution = value; - this.save(); - } - - /** - * Get raw JSON data - */ - toJSON(): ProjectConfigSchema { - return { ...this.data }; - } -} - -const CURRENT_USER_SCHEMA_VERSION = '1.0'; - -export interface UserSettingsSchema { - // Common values - version: string; - - // Local tools - aiAgents: AI_AGENT[]; - - // User preferences - defaults: { - aiAgent?: AI_AGENT; - }; -} - -/** - * User-specific configuration. Useful to tailor the rover behavior for an user. - * We do not expect users commiting this file to the repo. - */ -export class UserSettings { - constructor(private data: UserSettingsSchema) {} - - /** - * Load user settings from disk - */ - static load(): UserSettings { - const filePath = UserSettings.getSettingsPath(); - - if (!existsSync(filePath)) { - // Return default settings if file doesn't exist - return UserSettings.createDefault(); - } - - try { - const rawData = readFileSync(filePath, 'utf8'); - const parsedData = JSON.parse(rawData); - - // Migrate if necessary - const migratedData = UserSettings.migrate(parsedData); - - const instance = new UserSettings(migratedData); - - // If migration occurred, save the updated data - if (migratedData.version !== parsedData.version) { - instance.save(); - } - - return instance; - } catch (error) { - if (error instanceof SyntaxError) { - throw new InvalidFormatError(filePath); - } else { - throw new Error('Failed to load user settings.'); - } - } - } - - /** - * Create default user settings - */ - static createDefault(): UserSettings { - const schema: UserSettingsSchema = { - version: CURRENT_USER_SCHEMA_VERSION, - aiAgents: [], - defaults: {}, - }; - - const instance = new UserSettings(schema); - instance.save(); - return instance; - } - - /** - * Check if user settings exist - */ - static exists(): boolean { - const filePath = UserSettings.getSettingsPath(); - return existsSync(filePath); - } - - /** - * Get the path to the settings file - */ - private static getSettingsPath(): string { - const projectRoot = findProjectRoot(); - return join(projectRoot, '.rover', 'settings.json'); - } - - /** - * Migrate old settings to current schema version - */ - private static migrate(data: any): UserSettingsSchema { - // If already current version, return as-is - if (data.version === CURRENT_USER_SCHEMA_VERSION) { - return data as UserSettingsSchema; - } - - // For now, just ensure all required fields exist - const migrated: UserSettingsSchema = { - version: CURRENT_USER_SCHEMA_VERSION, - aiAgents: data.aiAgents || [AI_AGENT.Claude], - defaults: { - aiAgent: data.defaults?.aiAgent || AI_AGENT.Claude, - }, - }; - - return migrated; - } - - /** - * Save current settings to disk - */ - save(): void { - const filePath = UserSettings.getSettingsPath(); - const projectRoot = findProjectRoot(); - const dirPath = join(projectRoot, '.rover'); - - try { - // Ensure .rover directory exists - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }); - } - - const json = JSON.stringify(this.data, null, 2); - writeFileSync(filePath, json, 'utf8'); - } catch (error) { - throw new Error(`Failed to save user settings: ${error}`); - } - } - - /** - * Reload settings from disk - */ - reload(): void { - const reloaded = UserSettings.load(); - this.data = reloaded.data; - } - - // Data Access (Getters) - get version(): string { - return this.data.version; - } - get aiAgents(): AI_AGENT[] { - return this.data.aiAgents; - } - get defaultAiAgent(): AI_AGENT | undefined { - return this.data.defaults.aiAgent; - } - - // Data Modification (Setters) - setDefaultAiAgent(agent: AI_AGENT): void { - this.data.defaults.aiAgent = agent; - // Ensure the agent is in the available agents list - if (!this.data.aiAgents.includes(agent)) { - this.data.aiAgents.push(agent); - } - this.save(); - } - - addAiAgent(agent: AI_AGENT): void { - if (!this.data.aiAgents.includes(agent)) { - this.data.aiAgents.push(agent); - this.save(); - } - } - - removeAiAgent(agent: AI_AGENT): void { - const index = this.data.aiAgents.indexOf(agent); - if (index > -1) { - this.data.aiAgents.splice(index, 1); - // If we removed the default agent, set a new default - if ( - this.data.defaults.aiAgent === agent && - this.data.aiAgents.length > 0 - ) { - this.data.defaults.aiAgent = this.data.aiAgents[0]; - } - this.save(); - } - } - - /** - * Get raw JSON data - */ - toJSON(): UserSettingsSchema { - return { ...this.data }; - } -} diff --git a/packages/cli/src/lib/entrypoint.sh b/packages/cli/src/lib/entrypoint.sh index 1ac4e610..febd0d8e 100644 --- a/packages/cli/src/lib/entrypoint.sh +++ b/packages/cli/src/lib/entrypoint.sh @@ -21,10 +21,17 @@ export PATH=/root/local/.bin:$PATH # permissions to the minimal. sudo mkdir -p $HOME sudo mkdir -p $HOME/.config +sudo mkdir -p $HOME/.local/bin +echo 'export PATH="$HOME/.local/bin:$PATH"' >> $HOME/.profile sudo chown -R $(id -u):$(id -g) $HOME sudo chown -R $(id -u):$(id -g) /workspace sudo chown -R $(id -u):$(id -g) /output +source $HOME/.profile + +# Fail if node is not available +check_command "node" || safe_exit 1 + # Function to shred secrets before exit shred_secrets() { # Remove credentials: on certain environments such as Darwin, @@ -117,6 +124,8 @@ echo "Task ID: $TASK_ID" echo "Task Iteration: $TASK_ITERATION" echo "=======================================" +{installAllPackages} + # Agent-specific CLI installation and credential setup echo -e "\n📦 Installing Agent CLI and setting up credentials" # Pass the environment variables to ensure it loads the right credentials @@ -171,6 +180,8 @@ export TASK_ID TASK_TITLE TASK_DESCRIPTION echo -e "\n👤 Removing privileges after completing the setup!" sudo rm /etc/sudoers.d/1-agent-setup +{initScriptExecution} + # Execute the complete task workflow echo -e "\n=======================================" echo "🚀 Running Workflow" diff --git a/packages/cli/src/lib/global-state.ts b/packages/cli/src/lib/global-state.ts new file mode 100644 index 00000000..3f4a9165 --- /dev/null +++ b/packages/cli/src/lib/global-state.ts @@ -0,0 +1,22 @@ +/** + * Global state for CLI execution. + * This module stores runtime flags that need to be accessible throughout the application. + */ + +let _isJsonMode = false; + +/** + * Set whether the CLI is running in JSON output mode. + * This should be called early in the program execution (e.g., in preAction hooks). + */ +export function setJsonMode(value: boolean): void { + _isJsonMode = value; +} + +/** + * Check if the CLI is running in JSON output mode. + * When in JSON mode, human-readable console output should be suppressed. + */ +export function isJsonMode(): boolean { + return _isJsonMode; +} diff --git a/packages/cli/src/lib/sandbox/container-common.ts b/packages/cli/src/lib/sandbox/container-common.ts index 67253756..9854be39 100644 --- a/packages/cli/src/lib/sandbox/container-common.ts +++ b/packages/cli/src/lib/sandbox/container-common.ts @@ -1,6 +1,93 @@ import { launch } from 'rover-common'; +import { ProjectConfigManager } from 'rover-schemas'; +import colors from 'ansi-colors'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Dynamically resolves the default agent image based on CLI version. + * Allows override via ROVER_AGENT_IMAGE environment variable. + * + * @returns The default agent image tag + */ +export function getDefaultAgentImage(): string { + // Allow override via environment variable + if (process.env.ROVER_AGENT_IMAGE) { + return process.env.ROVER_AGENT_IMAGE; + } -export const AGENT_IMAGE = 'ghcr.io/endorhq/rover/node:v1.3.3'; + // Load from package.json version + try { + // After bundling, the code is in dist/index.js, so we need to go up one level + const packageJsonPath = join(__dirname, '../package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const version = packageJson.version; + + // Use agent-dev:latest for dev versions, agent:v{version} for production + if (version.includes('-dev')) { + return 'ghcr.io/endorhq/rover/agent-dev:latest'; + } else { + return `ghcr.io/endorhq/rover/agent:v${version}`; + } + } catch (_err) { + return 'ghcr.io/endorhq/rover/agent-dev:latest'; + } +} + +/** + * Resolves the agent image to use, with precedence: + * 1. AGENT_IMAGE environment variable + * 2. agentImage from ProjectConfig + * 3. Default image based on CLI version + */ +export function resolveAgentImage( + projectConfig?: ProjectConfigManager +): string { + // Check environment variable first + const envImage = process.env.AGENT_IMAGE; + if (envImage) { + return envImage; + } + + // Check project config if available + if (projectConfig?.agentImage) { + return projectConfig.agentImage; + } + + // Fall back to default image + return getDefaultAgentImage(); +} + +/** + * Checks if a custom agent image is being used and prints a warning if so + */ +export function warnIfCustomImage(projectConfig?: ProjectConfigManager): void { + const envImage = process.env.AGENT_IMAGE; + const configImage = projectConfig?.agentImage; + + // Only warn if a custom image is configured (not using the default) + if (envImage || configImage) { + const customImage = envImage || configImage; + const defaultImage = getDefaultAgentImage(); + console.log( + colors.yellow( + '\n⚠ Note: Using custom agent image: ' + colors.cyan(customImage!) + ) + ); + console.log( + colors.yellow( + ' This might have side effects on the expected behavior of Rover if this image is incompatible' + ) + ); + console.log( + colors.yellow(' with the reference image: ' + colors.cyan(defaultImage)) + ); + } +} export type CurrentUser = string; export type CurrentGroup = string; diff --git a/packages/cli/src/lib/sandbox/docker.ts b/packages/cli/src/lib/sandbox/docker.ts index 4f19319d..f13bbd0c 100644 --- a/packages/cli/src/lib/sandbox/docker.ts +++ b/packages/cli/src/lib/sandbox/docker.ts @@ -1,28 +1,26 @@ -import { getAIAgentTool, getUserAIAgent } from '../agents/index.js'; +import { getAIAgentTool } from '../agents/index.js'; import { join } from 'node:path'; -import { ProjectConfig } from '../config.js'; +import { ProjectConfigManager } from 'rover-schemas'; import { Sandbox } from './types.js'; import { SetupBuilder } from '../setup.js'; import { TaskDescriptionManager } from 'rover-schemas'; -import { - AI_AGENT, - findProjectRoot, - launch, - ProcessManager, -} from 'rover-common'; +import { findProjectRoot, launch, ProcessManager } from 'rover-common'; import { parseCustomEnvironmentVariables, loadEnvsFile, } from '../../utils/env-variables.js'; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { homedir, tmpdir, userInfo } from 'node:os'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir, userInfo } from 'node:os'; import { generateRandomId } from '../../utils/branch-name.js'; import { - AGENT_IMAGE, ContainerBackend, etcPasswdWithUserInfo, etcGroupWithUserInfo, + resolveAgentImage, + warnIfCustomImage, } from './container-common.js'; +import { isJsonMode } from '../global-state.js'; +import colors from 'ansi-colors'; export class DockerSandbox extends Sandbox { backend = ContainerBackend.Docker; @@ -62,7 +60,12 @@ export class DockerSandbox extends Sandbox { ); // Generate setup script using SetupBuilder - const setupBuilder = new SetupBuilder(this.task, this.task.agent!); + const projectConfigForSetup = ProjectConfigManager.load(); + const setupBuilder = new SetupBuilder( + this.task, + this.task.agent!, + projectConfigForSetup + ); const entrypointScriptPath = setupBuilder.generateEntrypoint(); const inputsPath = setupBuilder.generateInputs(); const workflowPath = setupBuilder.saveWorkflow(this.task.workflowName); @@ -75,10 +78,11 @@ export class DockerSandbox extends Sandbox { // Load project config and merge custom environment variables const projectRoot = findProjectRoot(); let customEnvVariables: string[] = []; + let projectConfig: ProjectConfigManager | undefined; - if (ProjectConfig.exists()) { + if (ProjectConfigManager.exists()) { try { - const projectConfig = ProjectConfig.load(); + projectConfig = ProjectConfigManager.load(); // Parse custom envs array if (projectConfig.envs && projectConfig.envs.length > 0) { @@ -144,11 +148,17 @@ export class DockerSandbox extends Sandbox { userInfo_.gid = 1000; } + // Resolve the agent image from env var, config, or default + const agentImage = resolveAgentImage(projectConfig); + + // Warn if using a custom agent image + warnIfCustomImage(projectConfig); + const userCredentialsTempPath = mkdtempSync(join(tmpdir(), 'rover-')); const etcPasswd = join(userCredentialsTempPath, 'passwd'); const [etcPasswdContents, username] = await etcPasswdWithUserInfo( ContainerBackend.Docker, - AGENT_IMAGE, + agentImage, userInfo_ ); writeFileSync(etcPasswd, etcPasswdContents); @@ -156,7 +166,7 @@ export class DockerSandbox extends Sandbox { const etcGroup = join(userCredentialsTempPath, 'group'); const [etcGroupContents, group] = await etcGroupWithUserInfo( ContainerBackend.Docker, - AGENT_IMAGE, + agentImage, userInfo_ ); writeFileSync(etcGroup, etcGroupContents); @@ -180,13 +190,30 @@ export class DockerSandbox extends Sandbox { '-v', `${inputsPath}:/inputs.json:Z,ro`, '-v', - `${iterationJsonPath}:/task/description.json:Z,ro`, + `${iterationJsonPath}:/task/description.json:Z,ro` + ); + + // Mount initScript if provided in project config + if (projectConfig?.initScript) { + const initScriptAbsPath = join(projectRoot, projectConfig.initScript); + if (existsSync(initScriptAbsPath)) { + dockerArgs.push('-v', `${initScriptAbsPath}:/init-script.sh:Z,ro`); + } else if (!isJsonMode()) { + console.log( + colors.yellow( + `⚠ Warning: initScript '${projectConfig.initScript}' does not exist` + ) + ); + } + } + + dockerArgs.push( ...allEnvVariables, '-w', '/workspace', '--entrypoint', '/entrypoint.sh', - AGENT_IMAGE, + agentImage, 'rover-agent', 'run', '/workflow.yml', diff --git a/packages/cli/src/lib/sandbox/languages/go.ts b/packages/cli/src/lib/sandbox/languages/go.ts new file mode 100644 index 00000000..cbda9dda --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/go.ts @@ -0,0 +1,19 @@ +import { SandboxPackage } from '../types.js'; + +export class GoSandboxPackage extends SandboxPackage { + // Name of the package + name = 'go'; + + installScript(): string { + // Install go + return `sudo apk add --no-cache go`; + } + + initScript(): string { + // Add the go env to the profile + return `mkdir -p $HOME/go/bin +echo 'export PATH="$HOME/go/bin:$PATH"' >> $HOME/.profile +echo 'export GOPATH="$HOME/go"' >> $HOME/.profile +source $HOME/.profile`; + } +} diff --git a/packages/cli/src/lib/sandbox/languages/javascript.ts b/packages/cli/src/lib/sandbox/languages/javascript.ts new file mode 100644 index 00000000..e1dc7273 --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/javascript.ts @@ -0,0 +1,19 @@ +import { SandboxPackage } from '../types.js'; + +export class JavaScriptSandboxPackage extends SandboxPackage { + // Name of the package + name = 'javascript'; + + installScript(): string { + // Nothing required here. + return ``; + } + + initScript(): string { + // Configure node to install global modules locally for the user + return `mkdir -p $HOME/.local/npm; +echo "prefix=$HOME/.local/npm" >> $HOME/.npmrc; +echo 'export PATH="$HOME/.local/npm/bin:$PATH"' >> $HOME/.profile; +source $HOME/.profile`; + } +} diff --git a/packages/cli/src/lib/sandbox/languages/php.ts b/packages/cli/src/lib/sandbox/languages/php.ts new file mode 100644 index 00000000..f7bb7ee3 --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/php.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class PHPSandboxPackage extends SandboxPackage { + // Name of the package + name = 'php'; + + installScript(): string { + // Install php. If build base is required, the agent will take care of installing it + return `sudo apk add --no-cache php84 php84-dev`; + } + + initScript(): string { + // Nothing for now + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/languages/python.ts b/packages/cli/src/lib/sandbox/languages/python.ts new file mode 100644 index 00000000..92ee8156 --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/python.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class PythonSandboxPackage extends SandboxPackage { + // Name of the package + name = 'python'; + + installScript(): string { + // Install python-dev. Python is already installed in the base image + return `sudo apk add --no-cache python3-dev +sudo ln -sf python3 /usr/bin/python`; + } + + initScript(): string { + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/languages/ruby.ts b/packages/cli/src/lib/sandbox/languages/ruby.ts new file mode 100644 index 00000000..fc2e992f --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/ruby.ts @@ -0,0 +1,18 @@ +import { SandboxPackage } from '../types.js'; + +export class RubySandboxPackage extends SandboxPackage { + // Name of the package + name = 'ruby'; + + installScript(): string { + // Install ruby. If build base is required, the agent will take care of installing it + return `sudo apk add --no-cache ruby ruby-dev`; + } + + initScript(): string { + // Configure gem to avoid installing documentation and set user install path + return `echo "gem: --no-document --user-install" > $HOME/.gemrc +echo 'export PATH="$(ruby -e "puts Gem.user_dir")/bin:$PATH"' >> $HOME/.profile +source $HOME/.profile`; + } +} diff --git a/packages/cli/src/lib/sandbox/languages/rust.ts b/packages/cli/src/lib/sandbox/languages/rust.ts new file mode 100644 index 00000000..716fb64a --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/rust.ts @@ -0,0 +1,18 @@ +import { SandboxPackage } from '../types.js'; + +export class RustSandboxPackage extends SandboxPackage { + // Name of the package + name = 'rust'; + + installScript(): string { + // Install rust + return `sudo apk add --no-cache rustup +rustup-init -y`; + } + + initScript(): string { + // Add the cargo env to the profile + return `echo '. "$HOME/.cargo/env"' >> $HOME/.profile +source $HOME/.profile`; + } +} diff --git a/packages/cli/src/lib/sandbox/languages/typescript.ts b/packages/cli/src/lib/sandbox/languages/typescript.ts new file mode 100644 index 00000000..5ec1e84a --- /dev/null +++ b/packages/cli/src/lib/sandbox/languages/typescript.ts @@ -0,0 +1,7 @@ +import { JavaScriptSandboxPackage } from './javascript.js'; + +// For now, just reuse it +export class TypeScriptSandboxPackage extends JavaScriptSandboxPackage { + // Name of the package + name = 'typescript'; +} diff --git a/packages/cli/src/lib/sandbox/package-managers/cargo.ts b/packages/cli/src/lib/sandbox/package-managers/cargo.ts new file mode 100644 index 00000000..23913381 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/cargo.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class CargoSandboxPackage extends SandboxPackage { + // Name of the package + name = 'cargo'; + + installScript(): string { + // cargo is installed via rustup in the rust language package + return ``; + } + + initScript(): string { + // cargo environment is already configured by rust language package + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/composer.ts b/packages/cli/src/lib/sandbox/package-managers/composer.ts new file mode 100644 index 00000000..79a1d906 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/composer.ts @@ -0,0 +1,28 @@ +import { SandboxPackage } from '../types.js'; + +export class ComposerSandboxPackage extends SandboxPackage { + // Name of the package + name = 'composer'; + + installScript(): string { + // Install Composer using official method (requires PHP which should be installed via php language package) + // Download installer, verify SHA-384 hash, run installer, cleanup, and move to user-local bin + return `php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" +EXPECTED_CHECKSUM="$(php -r 'copy("https://getcomposer.org/download/latest-stable/composer.phar.sha256", "php://stdout");')" +ACTUAL_CHECKSUM="$(sha256sum composer-setup.php | awk '{print $1}')" +if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then + >&2 echo 'ERROR: Invalid installer checksum' + rm composer-setup.php + exit 1 +fi +php composer-setup.php --quiet +rm composer-setup.php +mkdir -p $HOME/.local/bin +mv composer.phar $HOME/.local/bin/composer`; + } + + initScript(): string { + // Configure Composer to use user-local paths + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/gomod.ts b/packages/cli/src/lib/sandbox/package-managers/gomod.ts new file mode 100644 index 00000000..5dcf627b --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/gomod.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class GomodSandboxPackage extends SandboxPackage { + // Name of the package + name = 'gomod'; + + installScript(): string { + // go mod is built into Go 1.11+, no additional installation needed + return ``; + } + + initScript(): string { + // go mod uses GOPATH which is configured by go language package + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/npm.ts b/packages/cli/src/lib/sandbox/package-managers/npm.ts new file mode 100644 index 00000000..e949fcba --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/npm.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class NpmSandboxPackage extends SandboxPackage { + // Name of the package + name = 'npm'; + + installScript(): string { + // npm is already included in node:alpine base image + return ``; + } + + initScript(): string { + // Aldready preconfigured in javascript package + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/pip.ts b/packages/cli/src/lib/sandbox/package-managers/pip.ts new file mode 100644 index 00000000..61e7be54 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/pip.ts @@ -0,0 +1,15 @@ +import { SandboxPackage } from '../types.js'; + +export class PipSandboxPackage extends SandboxPackage { + // Name of the package + name = 'pip'; + + installScript(): string { + // Install pip + return `sudo apk add --no-cache py3-pip`; + } + + initScript(): string { + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/pnpm.ts b/packages/cli/src/lib/sandbox/package-managers/pnpm.ts new file mode 100644 index 00000000..4c9c1158 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/pnpm.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class PnpmSandboxPackage extends SandboxPackage { + // Name of the package + name = 'pnpm'; + + installScript(): string { + // Install pnpm using npm + return `npm install -g pnpm`; + } + + initScript(): string { + // pnpm automatically uses user-local directories + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/poetry.ts b/packages/cli/src/lib/sandbox/package-managers/poetry.ts new file mode 100644 index 00000000..ce65f021 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/poetry.ts @@ -0,0 +1,15 @@ +import { SandboxPackage } from '../types.js'; + +export class PoetrySandboxPackage extends SandboxPackage { + // Name of the package + name = 'poetry'; + + installScript(): string { + // Install Poetry using the official installer (requires Python) + return `curl -sSL https://install.python-poetry.org | python3 -`; + } + + initScript(): string { + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/rubygems.ts b/packages/cli/src/lib/sandbox/package-managers/rubygems.ts new file mode 100644 index 00000000..0a9a9038 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/rubygems.ts @@ -0,0 +1,17 @@ +import { SandboxPackage } from '../types.js'; + +export class RubygemsSandboxPackage extends SandboxPackage { + // Name of the package + name = 'rubygems'; + + installScript(): string { + // Install bundler + return `gem install bundler`; + } + + initScript(): string { + // Configure it to use a local folder by default + return `mkdir -p $HOME/.bundle +bundle config set --global path $HOME/.bundle`; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/uv.ts b/packages/cli/src/lib/sandbox/package-managers/uv.ts new file mode 100644 index 00000000..9a2d03f9 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/uv.ts @@ -0,0 +1,15 @@ +import { SandboxPackage } from '../types.js'; + +export class UvSandboxPackage extends SandboxPackage { + // Name of the package + name = 'uv'; + + installScript(): string { + // Already preinstalled in the image for MCPs + return ``; + } + + initScript(): string { + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/package-managers/yarn.ts b/packages/cli/src/lib/sandbox/package-managers/yarn.ts new file mode 100644 index 00000000..b2167ea7 --- /dev/null +++ b/packages/cli/src/lib/sandbox/package-managers/yarn.ts @@ -0,0 +1,19 @@ +import { SandboxPackage } from '../types.js'; + +export class YarnSandboxPackage extends SandboxPackage { + // Name of the package + name = 'yarn'; + + installScript(): string { + // yarn is typically included in node:alpine, but ensure it's installed + return `npm install -g yarn`; + } + + initScript(): string { + // Configure yarn to install global binaries locally for the user + return `mkdir -p $HOME/.yarn/bin; +yarn config set prefix $HOME/.yarn; +echo 'export PATH="$HOME/.yarn/bin:$PATH"' >> $HOME/.profile; +source $HOME/.profile`; + } +} diff --git a/packages/cli/src/lib/sandbox/podman.ts b/packages/cli/src/lib/sandbox/podman.ts index 4e8ef5d6..ca2b8d13 100644 --- a/packages/cli/src/lib/sandbox/podman.ts +++ b/packages/cli/src/lib/sandbox/podman.ts @@ -1,6 +1,6 @@ -import { getAIAgentTool, getUserAIAgent } from '../agents/index.js'; +import { getAIAgentTool } from '../agents/index.js'; import { join } from 'node:path'; -import { ProjectConfig } from '../config.js'; +import { ProjectConfigManager } from 'rover-schemas'; import { Sandbox } from './types.js'; import { SetupBuilder } from '../setup.js'; import { TaskDescriptionManager } from 'rover-schemas'; @@ -14,15 +14,18 @@ import { parseCustomEnvironmentVariables, loadEnvsFile, } from '../../utils/env-variables.js'; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { homedir, tmpdir, userInfo } from 'node:os'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir, userInfo } from 'node:os'; import { generateRandomId } from '../../utils/branch-name.js'; import { - AGENT_IMAGE, ContainerBackend, etcPasswdWithUserInfo, etcGroupWithUserInfo, + resolveAgentImage, + warnIfCustomImage, } from './container-common.js'; +import { isJsonMode } from '../global-state.js'; +import colors from 'ansi-colors'; export class PodmanSandbox extends Sandbox { backend = ContainerBackend.Podman; @@ -58,7 +61,12 @@ export class PodmanSandbox extends Sandbox { ); // Generate setup script using SetupBuilder - const setupBuilder = new SetupBuilder(this.task, this.task.agent!); + const projectConfigForSetup = ProjectConfigManager.load(); + const setupBuilder = new SetupBuilder( + this.task, + this.task.agent!, + projectConfigForSetup + ); const entrypointScriptPath = setupBuilder.generateEntrypoint(); const inputsPath = setupBuilder.generateInputs(); const workflowPath = setupBuilder.saveWorkflow(this.task.workflowName); @@ -71,10 +79,11 @@ export class PodmanSandbox extends Sandbox { // Load project config and merge custom environment variables const projectRoot = findProjectRoot(); let customEnvVariables: string[] = []; + let projectConfig: ProjectConfigManager | undefined; - if (ProjectConfig.exists()) { + if (ProjectConfigManager.exists()) { try { - const projectConfig = ProjectConfig.load(); + projectConfig = ProjectConfigManager.load(); // Parse custom envs array if (projectConfig.envs && projectConfig.envs.length > 0) { @@ -114,11 +123,17 @@ export class PodmanSandbox extends Sandbox { const userInfo_ = userInfo(); + // Resolve the agent image from env var, config, or default + const agentImage = resolveAgentImage(projectConfig); + + // Warn if using a custom agent image + warnIfCustomImage(projectConfig); + const userCredentialsTempPath = mkdtempSync(join(tmpdir(), 'rover-')); const etcPasswd = join(userCredentialsTempPath, 'passwd'); const [etcPasswdContents, username] = await etcPasswdWithUserInfo( ContainerBackend.Podman, - AGENT_IMAGE, + agentImage, userInfo_ ); writeFileSync(etcPasswd, etcPasswdContents); @@ -126,7 +141,7 @@ export class PodmanSandbox extends Sandbox { const etcGroup = join(userCredentialsTempPath, 'group'); const [etcGroupContents, group] = await etcGroupWithUserInfo( ContainerBackend.Podman, - AGENT_IMAGE, + agentImage, userInfo_ ); writeFileSync(etcGroup, etcGroupContents); @@ -150,13 +165,30 @@ export class PodmanSandbox extends Sandbox { '-v', `${inputsPath}:/inputs.json:Z,ro`, '-v', - `${iterationJsonPath}:/task/description.json:Z,ro`, + `${iterationJsonPath}:/task/description.json:Z,ro` + ); + + // Mount initScript if provided in project config + if (projectConfig?.initScript) { + const initScriptAbsPath = join(projectRoot, projectConfig.initScript); + if (existsSync(initScriptAbsPath)) { + podmanArgs.push('-v', `${initScriptAbsPath}:/init-script.sh:Z,ro`); + } else if (!isJsonMode()) { + console.log( + colors.yellow( + `⚠ Warning: initScript '${projectConfig.initScript}' does not exist` + ) + ); + } + } + + podmanArgs.push( ...allEnvVariables, '-w', '/workspace', '--entrypoint', '/entrypoint.sh', - AGENT_IMAGE, + agentImage, 'rover-agent', 'run', '/workflow.yml', diff --git a/packages/cli/src/lib/sandbox/task-managers/just.ts b/packages/cli/src/lib/sandbox/task-managers/just.ts new file mode 100644 index 00000000..d35e6e45 --- /dev/null +++ b/packages/cli/src/lib/sandbox/task-managers/just.ts @@ -0,0 +1,15 @@ +import { SandboxPackage } from '../types.js'; + +export class JustSandboxPackage extends SandboxPackage { + // Name of the package + name = 'just'; + + installScript(): string { + // Install just command runner + return `sudo apk add just`; + } + + initScript(): string { + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/task-managers/make.ts b/packages/cli/src/lib/sandbox/task-managers/make.ts new file mode 100644 index 00000000..e7415385 --- /dev/null +++ b/packages/cli/src/lib/sandbox/task-managers/make.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class MakeSandboxPackage extends SandboxPackage { + // Name of the package + name = 'make'; + + installScript(): string { + // Install GNU Make + return `sudo apk add --no-cache make`; + } + + initScript(): string { + // make is installed system-wide, no user configuration needed + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/task-managers/task.ts b/packages/cli/src/lib/sandbox/task-managers/task.ts new file mode 100644 index 00000000..8e8b08b3 --- /dev/null +++ b/packages/cli/src/lib/sandbox/task-managers/task.ts @@ -0,0 +1,16 @@ +import { SandboxPackage } from '../types.js'; + +export class TaskSandboxPackage extends SandboxPackage { + // Name of the package + name = 'task'; + + installScript(): string { + // Install Task (go-task) - a task runner / build tool written in Go + // Download the install script and run it + return `sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b $HOME/.local/bin`; + } + + initScript(): string { + return ``; + } +} diff --git a/packages/cli/src/lib/sandbox/types.ts b/packages/cli/src/lib/sandbox/types.ts index 8f9f84b5..faa38d4e 100644 --- a/packages/cli/src/lib/sandbox/types.ts +++ b/packages/cli/src/lib/sandbox/types.ts @@ -2,6 +2,13 @@ import { ProcessManager } from 'rover-common'; import { TaskDescriptionManager } from 'rover-schemas'; +export abstract class SandboxPackage { + abstract name: string; + + abstract installScript(): string; + abstract initScript(): string; +} + export abstract class Sandbox { abstract backend: string; diff --git a/packages/cli/src/lib/setup.ts b/packages/cli/src/lib/setup.ts index b5bfd7e6..97e3aa84 100644 --- a/packages/cli/src/lib/setup.ts +++ b/packages/cli/src/lib/setup.ts @@ -7,7 +7,34 @@ import techWriterWorkflow from './workflows/tech-writer.yml'; import entrypointScript from './entrypoint.sh'; import pupa from 'pupa'; import { fileURLToPath } from 'node:url'; -import { ProjectConfig } from './config.js'; +import { ProjectConfigManager } from 'rover-schemas'; +import type { SandboxPackage } from './sandbox/types.js'; + +// Language packages +import { JavaScriptSandboxPackage } from './sandbox/languages/javascript.js'; +import { TypeScriptSandboxPackage } from './sandbox/languages/typescript.js'; +import { PHPSandboxPackage } from './sandbox/languages/php.js'; +import { RustSandboxPackage } from './sandbox/languages/rust.js'; +import { GoSandboxPackage } from './sandbox/languages/go.js'; +import { PythonSandboxPackage } from './sandbox/languages/python.js'; +import { RubySandboxPackage } from './sandbox/languages/ruby.js'; + +// Package manager packages +import { NpmSandboxPackage } from './sandbox/package-managers/npm.js'; +import { PnpmSandboxPackage } from './sandbox/package-managers/pnpm.js'; +import { YarnSandboxPackage } from './sandbox/package-managers/yarn.js'; +import { ComposerSandboxPackage } from './sandbox/package-managers/composer.js'; +import { CargoSandboxPackage } from './sandbox/package-managers/cargo.js'; +import { GomodSandboxPackage } from './sandbox/package-managers/gomod.js'; +import { PipSandboxPackage } from './sandbox/package-managers/pip.js'; +import { PoetrySandboxPackage } from './sandbox/package-managers/poetry.js'; +import { UvSandboxPackage } from './sandbox/package-managers/uv.js'; +import { RubygemsSandboxPackage } from './sandbox/package-managers/rubygems.js'; + +// Task manager packages +import { JustSandboxPackage } from './sandbox/task-managers/just.js'; +import { MakeSandboxPackage } from './sandbox/task-managers/make.js'; +import { TaskSandboxPackage } from './sandbox/task-managers/task.js'; /** * SetupBuilder class - Consolidates Docker setup script generation @@ -18,10 +45,16 @@ export class SetupBuilder { private task: TaskDescriptionManager; private taskDir: string; private isDockerRootless: boolean; + private projectConfig: ProjectConfigManager; - constructor(taskDescription: TaskDescriptionManager, agent: string) { + constructor( + taskDescription: TaskDescriptionManager, + agent: string, + projectConfig: ProjectConfigManager + ) { this.agent = agent; this.task = taskDescription; + this.projectConfig = projectConfig; let isDockerRootless = false; @@ -47,6 +80,108 @@ export class SetupBuilder { this.taskDir = taskDir; } + /** + * Get language sandbox packages based on project configuration + */ + private getLanguagePackages(): SandboxPackage[] { + const packages: SandboxPackage[] = []; + + for (const language of this.projectConfig.languages) { + switch (language) { + case 'javascript': + packages.push(new JavaScriptSandboxPackage()); + break; + case 'typescript': + packages.push(new TypeScriptSandboxPackage()); + break; + case 'php': + packages.push(new PHPSandboxPackage()); + break; + case 'rust': + packages.push(new RustSandboxPackage()); + break; + case 'go': + packages.push(new GoSandboxPackage()); + break; + case 'python': + packages.push(new PythonSandboxPackage()); + break; + case 'ruby': + packages.push(new RubySandboxPackage()); + break; + } + } + + return packages; + } + + /** + * Get package manager sandbox packages based on project configuration + */ + private getPackageManagerPackages(): SandboxPackage[] { + const packages: SandboxPackage[] = []; + + for (const packageManager of this.projectConfig.packageManagers) { + switch (packageManager) { + case 'npm': + packages.push(new NpmSandboxPackage()); + break; + case 'pnpm': + packages.push(new PnpmSandboxPackage()); + break; + case 'yarn': + packages.push(new YarnSandboxPackage()); + break; + case 'composer': + packages.push(new ComposerSandboxPackage()); + break; + case 'cargo': + packages.push(new CargoSandboxPackage()); + break; + case 'gomod': + packages.push(new GomodSandboxPackage()); + break; + case 'pip': + packages.push(new PipSandboxPackage()); + break; + case 'poetry': + packages.push(new PoetrySandboxPackage()); + break; + case 'uv': + packages.push(new UvSandboxPackage()); + break; + case 'rubygems': + packages.push(new RubygemsSandboxPackage()); + break; + } + } + + return packages; + } + + /** + * Get task manager sandbox packages based on project configuration + */ + private getTaskManagerPackages(): SandboxPackage[] { + const packages: SandboxPackage[] = []; + + for (const taskManager of this.projectConfig.taskManagers) { + switch (taskManager) { + case 'just': + packages.push(new JustSandboxPackage()); + break; + case 'make': + packages.push(new MakeSandboxPackage()); + break; + case 'task': + packages.push(new TaskSandboxPackage()); + break; + } + } + + return packages; + } + /** * Generate and save the setup script to the appropriate task directory */ @@ -59,9 +194,59 @@ export class SetupBuilder { sudo chown -R root:root /output || true\n`; } + // Generate installation scripts for languages, package managers, and task managers + const languagePackages = this.getLanguagePackages(); + const packageManagerPackages = this.getPackageManagerPackages(); + const taskManagerPackages = this.getTaskManagerPackages(); + + let installAllPackages = ''; + const allPackages = [ + ...languagePackages, + ...packageManagerPackages, + ...taskManagerPackages, + ]; + + if (allPackages.length > 0) { + const installScripts: string[] = []; + + for (const pkg of allPackages) { + const script = pkg.installScript(); + if (script.trim()) { + installScripts.push(`echo "📦 Installing ${pkg.name}..."`); + installScripts.push(script); + installScripts.push(`if [ $? -eq 0 ]; then + echo "✅ ${pkg.name} installed successfully" +else + echo "❌ Failed to install ${pkg.name}" + safe_exit 1 +fi`); + } + + const initScript = pkg.initScript(); + if (initScript.trim()) { + installScripts.push(`echo "🔧 Initializing ${pkg.name}..."`); + installScripts.push(initScript); + installScripts.push(`if [ $? -eq 0 ]; then + echo "✅ ${pkg.name} initialized successfully" +else + echo "❌ Failed to initialize ${pkg.name}" + safe_exit 1 +fi`); + } + } + + if (installScripts.length > 0) { + installAllPackages = ` +echo -e "\\n=======================================" +echo "📦 Installing Languages, Package Managers, and Task Managers" +echo "=======================================" +${installScripts.join('\n')} +`; + } + } + // Generate MCP configuration commands from rover.json - const projectConfig = ProjectConfig.load(); - const mcps = projectConfig.mcps; + const mcps = this.projectConfig.mcps; let configureAllMCPCommands: string[] = []; if (mcps && mcps.length > 0) { @@ -92,11 +277,31 @@ export class SetupBuilder { ); } + // Generate initScript execution code if initScript is provided + let initScriptExecution = ''; + if (this.projectConfig.initScript) { + initScriptExecution = ` +echo -e "\\n=======================================" +echo "🔧 Running initialization script" +echo "=======================================" +chmod +x /init-script.sh +/bin/sh /init-script.sh +if [ $? -eq 0 ]; then + echo "✅ Initialization script completed successfully" +else + echo "❌ Initialization script failed" + safe_exit 1 +fi +`; + } + // Generate script content const scriptContent = pupa(entrypointScript, { agent: this.agent, configureAllMCPCommands: configureAllMCPCommands.join('\n '), recoverPermissions, + installAllPackages, + initScriptExecution, }); // Write script to file @@ -168,7 +373,8 @@ export class SetupBuilder { taskDescription: TaskDescriptionManager, agent: string ): string { - const builder = new SetupBuilder(taskDescription, agent); + const projectConfig = ProjectConfigManager.load(); + const builder = new SetupBuilder(taskDescription, agent, projectConfig); return builder.generateEntrypoint(); } } diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 21747513..8b13892e 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -1,5 +1,5 @@ import { Command, Option } from 'commander'; -import { ProjectConfig, UserSettings } from './lib/config.js'; +import { ProjectConfigManager, UserSettingsManager } from 'rover-schemas'; import { AI_AGENT } from 'rover-common'; import { initCommand } from './commands/init.js'; import { listCommand } from './commands/list.js'; @@ -27,6 +27,7 @@ import { showRegularHeader, } from 'rover-common'; import { addWorkflowCommands } from './commands/workflows/index.js'; +import { setJsonMode, isJsonMode } from './lib/global-state.js'; export function createProgram( options: { excludeRuntimeHooks?: boolean } = {} @@ -36,12 +37,19 @@ export function createProgram( if (!options.excludeRuntimeHooks) { program + .hook('preAction', (_thisCommand, actionCommand) => { + // Set global JSON mode flag based on command options + setJsonMode(actionCommand.opts().json === true); + }) .hook('preAction', (thisCommand, _actionCommand) => { setVerbose(thisCommand.opts().verbose); }) .hook('preAction', (_thisCommand, actionCommand) => { const commandName = actionCommand.name(); - if (!['init', 'mcp'].includes(commandName) && !ProjectConfig.exists()) { + if ( + !['init', 'mcp'].includes(commandName) && + !ProjectConfigManager.exists() + ) { console.log( `Rover is not initialized in this directory. The command you requested (\`${commandName}\`) was not executed.` ); @@ -73,7 +81,6 @@ export function createProgram( error: 'Git is not installed', success: false, }, - actionCommand.opts().json === true, { tips: ['Install git and try again'], } @@ -85,7 +92,6 @@ export function createProgram( error: 'Not in a git repository', success: false, }, - actionCommand.opts().json === true, { tips: [ 'Rover requires the project to be in a git repository. You can initialize a git repository by running ' + @@ -100,7 +106,6 @@ export function createProgram( error: 'No commits found in git repository', success: false, }, - actionCommand.opts().json === true, { tips: [ 'Git worktree requires at least one commit in the repository in order to have common history', @@ -113,8 +118,8 @@ export function createProgram( const commandName = actionCommand.name(); if ( !['init', 'mcp'].includes(commandName) && - ProjectConfig.exists() && - !UserSettings.exists() + ProjectConfigManager.exists() && + !UserSettingsManager.exists() ) { console.log( `Rover is not fully initialized in this directory. The command you requested (\`${commandName}\`) was not executed.` @@ -143,7 +148,7 @@ export function createProgram( .hook('preAction', (_thisCommand, actionCommand) => { const commandName = actionCommand.name(); - if (actionCommand.opts().json === true) { + if (isJsonMode()) { // Do not print anything for JSON return; } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 10d395a8..a486b923 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,4 +1,4 @@ -import { LANGUAGE, PACKAGE_MANAGER, TASK_MANAGER } from './lib/config.js'; +import type { Language, PackageManager, TaskManager } from 'rover-schemas'; export interface ProjectInstructions { runDev: string; @@ -52,9 +52,9 @@ export interface TaskExpansion { } export interface Environment { - languages: LANGUAGE[]; - packageManagers: PACKAGE_MANAGER[]; - taskManagers: TASK_MANAGER[]; + languages: Language[]; + packageManagers: PackageManager[]; + taskManagers: TaskManager[]; } export interface AIProvider { diff --git a/packages/cli/src/utils/environment.ts b/packages/cli/src/utils/environment.ts index ac65886e..9189b034 100644 --- a/packages/cli/src/utils/environment.ts +++ b/packages/cli/src/utils/environment.ts @@ -1,50 +1,50 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Environment } from '../types.js'; -import { LANGUAGE, PACKAGE_MANAGER, TASK_MANAGER } from '../lib/config.js'; +import type { Language, PackageManager, TaskManager } from 'rover-schemas'; /** * Identify project types based on the given files */ -const LANGUAGE_FILES = { - [LANGUAGE.TypeScript]: ['tsconfig.json', 'tsconfig.node.json'], - [LANGUAGE.Javascript]: ['package.json', '.node-version'], - [LANGUAGE.PHP]: ['composer.json', 'index.php', 'phpunit.xml'], - [LANGUAGE.Rust]: ['Cargo.toml'], - [LANGUAGE.Go]: ['go.mod', 'go.sum'], - [LANGUAGE.Ruby]: [ +const LANGUAGE_FILES: Record = { + typescript: ['tsconfig.json', 'tsconfig.node.json'], + javascript: ['package.json', '.node-version'], + php: ['composer.json', 'index.php', 'phpunit.xml'], + rust: ['Cargo.toml'], + go: ['go.mod', 'go.sum'], + ruby: [ '.ruby-version', 'Procfile.dev', 'Procfile.test', 'Gemfile', 'config.ru', ], - [LANGUAGE.Python]: ['pyproject.toml', 'uv.lock', 'setup.py', 'setup.cfg'], + python: ['pyproject.toml', 'uv.lock', 'setup.py', 'setup.cfg'], }; /** * Identify package managers from files */ -const PACKAGE_MANAGER_FILES = { - [PACKAGE_MANAGER.NPM]: ['package-lock.json'], - [PACKAGE_MANAGER.PNPM]: ['pnpm-lock.yaml'], - [PACKAGE_MANAGER.Yarn]: ['yarn.lock'], - [PACKAGE_MANAGER.Composer]: ['composer.lock'], - [PACKAGE_MANAGER.Cargo]: ['Cargo.toml', 'Cargo.lock'], - [PACKAGE_MANAGER.Gomod]: ['go.mod', 'go.sum'], - [PACKAGE_MANAGER.PIP]: ['pyproject.toml', '!poetry.lock', '!uv.lock'], - [PACKAGE_MANAGER.Poetry]: ['poetry.lock'], - [PACKAGE_MANAGER.UV]: ['uv.lock'], - [PACKAGE_MANAGER.Rubygems]: ['Gemfile', 'Gemfile.lock'], +const PACKAGE_MANAGER_FILES: Record = { + npm: ['package-lock.json'], + pnpm: ['pnpm-lock.yaml'], + yarn: ['yarn.lock'], + composer: ['composer.lock'], + cargo: ['Cargo.toml', 'Cargo.lock'], + gomod: ['go.mod', 'go.sum'], + pip: ['pyproject.toml', '!poetry.lock', '!uv.lock'], + poetry: ['poetry.lock'], + uv: ['uv.lock'], + rubygems: ['Gemfile', 'Gemfile.lock'], }; /** * Identify task managers from files */ -const TASK_MANAGER_FILES = { - [TASK_MANAGER.Just]: ['Justfile'], - [TASK_MANAGER.Make]: ['Makefile'], - [TASK_MANAGER.Task]: ['Taskfile.yml', 'Taskfile.yaml'], +const TASK_MANAGER_FILES: Record = { + just: ['Justfile'], + make: ['Makefile'], + task: ['Taskfile.yml', 'Taskfile.yaml'], }; /** @@ -81,12 +81,12 @@ function checkFilesMatch(projectPath: string, files: string[]): boolean { export async function detectLanguages( projectPath: string -): Promise { - const languages: LANGUAGE[] = []; +): Promise { + const languages: Language[] = []; for (const [language, files] of Object.entries(LANGUAGE_FILES)) { if (checkFilesMatch(projectPath, files)) { - languages.push(language as LANGUAGE); + languages.push(language as Language); } } @@ -95,12 +95,12 @@ export async function detectLanguages( export async function detectPackageManagers( projectPath: string -): Promise { - const packageManagers: PACKAGE_MANAGER[] = []; +): Promise { + const packageManagers: PackageManager[] = []; for (const [manager, files] of Object.entries(PACKAGE_MANAGER_FILES)) { if (checkFilesMatch(projectPath, files)) { - packageManagers.push(manager as PACKAGE_MANAGER); + packageManagers.push(manager as PackageManager); } } @@ -109,12 +109,12 @@ export async function detectPackageManagers( export async function detectTaskManagers( projectPath: string -): Promise { - const taskManagers: TASK_MANAGER[] = []; +): Promise { + const taskManagers: TaskManager[] = []; for (const [manager, files] of Object.entries(TASK_MANAGER_FILES)) { if (checkFilesMatch(projectPath, files)) { - taskManagers.push(manager as TASK_MANAGER); + taskManagers.push(manager as TaskManager); } } diff --git a/packages/cli/src/utils/exit.ts b/packages/cli/src/utils/exit.ts index b75e861f..e0dead7e 100644 --- a/packages/cli/src/utils/exit.ts +++ b/packages/cli/src/utils/exit.ts @@ -2,6 +2,7 @@ import colors from 'ansi-colors'; import { CLIJsonOutput, CLIJsonOutputWithErrors } from '../types.js'; import { showTips, TipsConfig } from './display.js'; import Telemetry from 'rover-telemetry'; +import { isJsonMode } from '../lib/global-state.js'; type ExitWithErrorOpts = { exitCode?: number; @@ -28,7 +29,6 @@ type ExitWithSuccessOpts = { */ export const exitWithError = ( jsonOutput: CLIJsonOutput, - json: boolean | undefined, options: ExitWithErrorOpts = {} ) => { exitWithErrors( @@ -36,7 +36,6 @@ export const exitWithError = ( success: jsonOutput.success, errors: jsonOutput.error ? [jsonOutput.error] : [], }, - json, options ); }; @@ -51,7 +50,6 @@ export const exitWithError = ( */ export const exitWithErrors = async ( jsonOutput: CLIJsonOutputWithErrors, - json: boolean | undefined, options: ExitWithErrorOpts = {} ) => { const { tips, tipsConfig, exitCode, telemetry } = options; @@ -61,7 +59,7 @@ export const exitWithErrors = async ( await telemetry.shutdown(); } - if (json === true) { + if (isJsonMode()) { console.log(JSON.stringify(jsonOutput, null, 2)); } else { for (const error of jsonOutput.errors) { @@ -80,7 +78,6 @@ export const exitWithErrors = async ( export const exitWithWarn = async ( warnMessage: string, jsonOutput: CLIJsonOutput, - json: boolean | undefined, options: ExitWithWarnOpts = {} ) => { const { tips, tipsConfig, exitCode, telemetry } = options; @@ -90,7 +87,7 @@ export const exitWithWarn = async ( await telemetry.shutdown(); } - if (json === true) { + if (isJsonMode()) { console.log(JSON.stringify(jsonOutput, null, 2)); } else { console.log(colors.yellow(`\n⚠ ${warnMessage}`)); @@ -108,7 +105,6 @@ export const exitWithWarn = async ( export const exitWithSuccess = async ( successMessage: string, jsonOutput: CLIJsonOutput, - json: boolean | undefined, options: ExitWithSuccessOpts = {} ) => { const { tips, tipsConfig, telemetry } = options; @@ -118,7 +114,7 @@ export const exitWithSuccess = async ( await telemetry.shutdown(); } - if (json === true) { + if (isJsonMode()) { console.log(JSON.stringify(jsonOutput, null, 2)); } else { console.log(colors.green(`\n✓ ${successMessage}`)); diff --git a/packages/extension/package.json b/packages/extension/package.json index 708a0c10..29cc4798 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,7 +2,7 @@ "name": "endor-rover", "displayName": "Rover", "description": "Collaborate with AI agents to complete any task", - "version": "1.4.0", + "version": "0.0.0-dev", "publisher": "endor", "engines": { "vscode": "^1.102.0" diff --git a/packages/extension/src/providers/TasksLitWebviewProvider.mts b/packages/extension/src/providers/TasksLitWebviewProvider.mts index 94502aff..96dde041 100644 --- a/packages/extension/src/providers/TasksLitWebviewProvider.mts +++ b/packages/extension/src/providers/TasksLitWebviewProvider.mts @@ -37,7 +37,8 @@ export class TasksLitWebviewProvider implements vscode.WebviewViewProvider { data.description, data.agent, data.branch, - data.workflow + data.workflow, + data.workflowInputs ); break; case 'refreshTasks': @@ -99,7 +100,8 @@ export class TasksLitWebviewProvider implements vscode.WebviewViewProvider { description: string, agent?: string, branch?: string, - workflow?: string + workflow?: string, + workflowInputs?: Record ) { try { // Show progress bar while creating task @@ -115,7 +117,8 @@ export class TasksLitWebviewProvider implements vscode.WebviewViewProvider { description.trim(), agent, branch, - workflow + workflow, + workflowInputs ); // Send success message back to webview diff --git a/packages/extension/src/rover/cli.mts b/packages/extension/src/rover/cli.mts index 6bd24b36..507ddf1d 100644 --- a/packages/extension/src/rover/cli.mts +++ b/packages/extension/src/rover/cli.mts @@ -8,11 +8,6 @@ import { } from './types.js'; import { findProjectRoot, launch, type Options } from 'rover-common'; -// TODO: Load workflows dynamically after we allow users to define their own workflows -const ROVER_DEFAULT_WORKFLOWS = [ - { id: 'swe', label: 'swe - Software Engineer for coding tasks' }, -]; - export class RoverCLI { private roverPath: string; private workspaceRoot: vscode.Uri | undefined; @@ -38,7 +33,7 @@ export class RoverCLI { env: { ...process.env, // For now, disable the CLI telemetry as we will add it to the extension - ROVER_NO_TELEMETRY: 'true', + ROVER_NO_TELEMETRY: '1', }, }; } @@ -117,13 +112,66 @@ export class RoverCLI { } } + /** + * Get available workflows from CLI + */ + async getWorkflows(): Promise< + Array<{ + id: string; + label: string; + inputs?: Array<{ + name: string; + description: string; + type: string; + required: boolean; + default?: any; + }>; + }> + > { + try { + const { stdout, stderr, exitCode } = await launch( + this.roverPath, + ['workflows', 'list', '--json'], + this.getLaunchOptions() + ); + if (exitCode != 0 || !stdout) { + throw new Error( + `error listing workflows (stdout: ${stdout}; stderr: ${stderr}; exit code: ${exitCode})` + ); + } + const result = JSON.parse(stdout.toString()); + if (result.workflows) { + return result.workflows.map((wf: any) => ({ + id: wf.name, + label: `${wf.name} - ${wf.description}`, + inputs: wf.inputs, + })); + } + return []; + } catch (error) { + console.error('Failed to load workflows:', error); + // Return default workflow as fallback + return [{ id: 'swe', label: 'swe - Software Engineer for coding tasks' }]; + } + } + /** * Get user settings including available agents */ async getSettings(): Promise<{ aiAgents: string[]; defaultAgent: string; - workflows: Array<{ id: string; label: string }>; + workflows: Array<{ + id: string; + label: string; + inputs?: Array<{ + name: string; + description: string; + type: string; + required: boolean; + default?: any; + }>; + }>; }> { try { // Read the settings file directly from .rover/settings.json @@ -138,26 +186,30 @@ export class RoverCLI { await vscode.workspace.fs.readFile(settingsPath); const settings = JSON.parse(new TextDecoder().decode(settingsContent)); + const workflows = await this.getWorkflows(); + return { aiAgents: settings.aiAgents || ['claude'], defaultAgent: settings.defaults?.aiAgent || 'claude', - workflows: ROVER_DEFAULT_WORKFLOWS, + workflows, }; } catch (error) { // If file doesn't exist or can't be read, return defaults console.error('Failed to load settings:', error); + const workflows = await this.getWorkflows(); return { aiAgents: ['claude'], defaultAgent: 'claude', - workflows: ROVER_DEFAULT_WORKFLOWS, + workflows, }; } } catch (error) { console.error('Failed to load settings:', error); + const workflows = await this.getWorkflows(); return { aiAgents: ['claude'], defaultAgent: 'claude', - workflows: ROVER_DEFAULT_WORKFLOWS, + workflows, }; } } @@ -195,7 +247,8 @@ export class RoverCLI { description: string, agent?: string, sourceBranch?: string, - workflow?: string + workflow?: string, + workflowInputs?: Record ): Promise { const args = ['task', '--yes', '--json']; @@ -214,12 +267,41 @@ export class RoverCLI { args.push('--workflow', workflow); } - args.push(description); + // Prepare launch options with workflow inputs passed via stdin as JSON + let launchOptions = this.getLaunchOptions(); + const hasWorkflowInputs = + workflowInputs && Object.keys(workflowInputs).length > 0; + + // When using workflows with inputs, pass description via stdin along with other inputs + // Otherwise, pass it as a positional argument + if (workflow && (hasWorkflowInputs || description)) { + // Filter out empty values and include description + const filteredInputs: Record = { + description: description, + }; + + if (workflowInputs) { + for (const [key, value] of Object.entries(workflowInputs)) { + if (value !== undefined && value !== null && value !== '') { + filteredInputs[key] = value; + } + } + } + + // Pass all workflow inputs (including description) via stdin as JSON + launchOptions = { + ...launchOptions, + input: JSON.stringify(filteredInputs), + }; + } else { + // No workflow or no inputs, pass description as positional argument + args.push(description); + } const { stdout, stderr, exitCode } = await launch( this.roverPath, args, - this.getLaunchOptions() + launchOptions ); if (exitCode != 0 || !stdout) { throw new Error( diff --git a/packages/extension/src/views/components/create-form.mts b/packages/extension/src/views/components/create-form.mts index 165d16c0..1b2700f2 100644 --- a/packages/extension/src/views/components/create-form.mts +++ b/packages/extension/src/views/components/create-form.mts @@ -2,6 +2,20 @@ import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import styles from './create-form.css.mjs'; +interface WorkflowInput { + name: string; + description: string; + type: string; + required: boolean; + default?: any; +} + +interface Workflow { + id: string; + label: string; + inputs?: WorkflowInput[]; +} + @customElement('create-form') export class CreateForm extends LitElement { @property({ type: Object }) vscode: any = null; @@ -9,8 +23,7 @@ export class CreateForm extends LitElement { @property({ type: String }) defaultAgent: string = 'claude'; @property({ type: Array }) branches: string[] = ['main']; @property({ type: String }) defaultBranch: string = 'main'; - @property({ type: Array }) workflows: Array<{ id: string; label: string }> = - []; + @property({ type: Array }) workflows: Workflow[] = []; @property({ type: String }) defaultWorkflow: string = ''; @property({ type: String }) dropdownDirection: 'auto' | 'up' | 'down' = 'auto'; @@ -27,6 +40,7 @@ export class CreateForm extends LitElement { @state() private branchDropdownDirection: 'up' | 'down' = 'down'; @state() private workflowDropdownDirection: 'up' | 'down' = 'down'; @state() private errorMessage = ''; + @state() private workflowInputValues: Record = {}; private getAgentsList() { return this.agents.map(agent => ({ @@ -81,6 +95,7 @@ export class CreateForm extends LitElement { if (!this.selectedWorkflow && this.workflows.length > 0) { this.selectedWorkflow = this.defaultWorkflow || this.workflows[0]?.id || ''; + this.initializeWorkflowInputs(); } } @@ -119,10 +134,18 @@ export class CreateForm extends LitElement { // Ensure selected workflow is still valid when workflows list changes if (changedProperties.has('workflows') && this.workflows.length > 0) { const workflowIds = this.workflows.map(w => w.id); - if (!workflowIds.includes(this.selectedWorkflow)) { + const previouslySelectedWorkflowStillExists = workflowIds.includes( + this.selectedWorkflow + ); + + if (!previouslySelectedWorkflowStillExists) { + // Selected workflow no longer exists, switch to default this.selectedWorkflow = this.defaultWorkflow || this.workflows[0]?.id || ''; + // Only reinitialize inputs when switching workflows + this.initializeWorkflowInputs(); } + // If the selected workflow still exists, preserve user-entered values } } @@ -168,6 +191,21 @@ export class CreateForm extends LitElement { return; } + // Validate required workflow inputs + const workflow = this.getSelectedWorkflow(); + if (workflow && workflow.inputs) { + const requiredInputs = workflow.inputs.filter( + input => input.required && input.name !== 'description' + ); + for (const input of requiredInputs) { + const value = this.workflowInputValues[input.name]; + if (value === undefined || value === null || value === '') { + this.errorMessage = `Required field "${input.description || input.name}" is missing`; + return; + } + } + } + // Clear any previous error and start creating this.errorMessage = ''; this.creatingTask = true; @@ -179,6 +217,7 @@ export class CreateForm extends LitElement { agent: this.selectedAgent, branch: this.selectedBranch, workflow: this.selectedWorkflow, + workflowInputs: this.workflowInputValues, }); } } @@ -258,6 +297,54 @@ export class CreateForm extends LitElement { event.stopPropagation(); this.selectedWorkflow = workflowId; this.showWorkflowDropdown = false; + this.initializeWorkflowInputs(); + } + + private initializeWorkflowInputs() { + const workflow = this.workflows.find(w => w.id === this.selectedWorkflow); + if (!workflow || !workflow.inputs) { + this.workflowInputValues = {}; + return; + } + + const inputValues: Record = {}; + workflow.inputs.forEach(input => { + if (input.default !== undefined) { + inputValues[input.name] = input.default; + } else if (input.type === 'boolean') { + inputValues[input.name] = false; + } else { + inputValues[input.name] = ''; + } + }); + this.workflowInputValues = inputValues; + } + + private handleWorkflowInputChange(inputName: string, value: any) { + // Validate and sanitize the input value based on its type + const workflow = this.getSelectedWorkflow(); + const inputDef = workflow?.inputs?.find(input => input.name === inputName); + + if (inputDef) { + // Type-specific validation + if (inputDef.type === 'number') { + // Ensure it's a valid number or empty string for optional fields + if (value !== '' && (isNaN(value) || !isFinite(value))) { + // Invalid number, don't update + return; + } + } else if (inputDef.type === 'string') { + // Limit string length to prevent abuse (max 10000 characters) + if (typeof value === 'string' && value.length > 10000) { + value = value.substring(0, 10000); + } + } + } + + this.workflowInputValues = { + ...this.workflowInputValues, + [inputName]: value, + }; } private selectAgent(agentId: string, event: Event) { @@ -284,6 +371,83 @@ export class CreateForm extends LitElement { ); } + private renderWorkflowInputs() { + const workflow = this.getSelectedWorkflow(); + if (!workflow || !workflow.inputs || workflow.inputs.length === 0) { + return html``; + } + + // Filter out 'description' input as it's handled by the main description textarea + const customInputs = workflow.inputs.filter( + input => input.name !== 'description' + ); + if (customInputs.length === 0) { + return html``; + } + + return html` +
+ ${customInputs.map(input => { + const value = + this.workflowInputValues[input.name] ?? input.default ?? ''; + + return html` +
+ + ${input.type === 'boolean' + ? html` + + ` + : input.type === 'number' + ? html` + { + const val = (e.target as HTMLInputElement).value; + this.handleWorkflowInputChange( + input.name, + val === '' ? '' : Number(val) + ); + }} + /> + ` + : html` + + this.handleWorkflowInputChange( + input.name, + (e.target as HTMLInputElement).value + )} + /> + `} +
+ `; + })} +
+ `; + } + render() { const selectedAgent = this.getSelectedAgent(); const selectedWorkflow = this.getSelectedWorkflow(); @@ -363,6 +527,9 @@ export class CreateForm extends LitElement { ` : ''} + + ${this.renderWorkflowInputs()} +