diff --git a/.claude/commands/build-backstage-server.md b/.claude/commands/build-backstage-server.md deleted file mode 100644 index 98f5a0d..0000000 --- a/.claude/commands/build-backstage-server.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Build, lint, test, and run the backstage-server locally -allowed-tools: Bash, Read -model: sonnet ---- - -# Build Backstage Server - -Build, validate, and run the `backstage-server` subproject locally for inspection. Stop immediately on any failure and report the error. - -## Workflow - -Run each step sequentially from the `backstage-server/` directory. If any step fails, stop and report the failure clearly. - -1. **Install dependencies**: `cd backstage-server && bun install` -2. **Lint**: `cd backstage-server && bun run lint` -3. **Typecheck**: `cd backstage-server && bun run typecheck` -4. **Test**: `cd backstage-server && bun test` -5. **Build**: `cd backstage-server && bun run build` (builds frontend, embeds assets, produces standalone binary at `dist/backstage-server`) -6. **Verify binary**: `ls -lh backstage-server/dist/backstage-server` (confirm binary exists and report its size) -7. **Run dev server**: `cd backstage-server && bun run dev` (run in background with hot-reload on port 7007) -8. **Report**: Confirm the server is running at http://localhost:7007. Note that it proxies to the Operator API at :7008. - -## Notes - -- If port 7007 is already in use, report the conflict and suggest killing the existing process. -- To stop the dev server later, kill the background Bun process. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9072ad3..910075a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,11 @@ updates: groups: rust-deps: patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: cargo directory: "/opr8r" @@ -15,6 +20,24 @@ updates: groups: opr8r-deps: patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 + + - package-ecosystem: cargo + directory: "/zed-extension" + schedule: + interval: weekly + groups: + zed-deps: + patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: npm directory: "/vscode-extension" @@ -23,11 +46,21 @@ updates: groups: vscode-deps: patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: bundler directory: "/docs" schedule: interval: weekly + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: github-actions directory: "/" @@ -36,3 +69,10 @@ updates: groups: actions: patterns: ["*"] + ignore: + - dependency-name: "actions/*" + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 diff --git a/.github/workflows/backstage.yaml b/.github/workflows/backstage.yaml deleted file mode 100644 index 11c2d5c..0000000 --- a/.github/workflows/backstage.yaml +++ /dev/null @@ -1,155 +0,0 @@ -name: Backstage CI - -on: - pull_request: - paths: - - 'backstage-server/**' - - '.github/workflows/backstage.yaml' - workflow_dispatch: - -concurrency: - group: backstage-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' - -jobs: - app-ci: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Lint app - run: bun run lint:app - - name: Test app - run: bun run test:app - - backend-ci: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Lint backend - run: bun run lint:backend - - name: Test backend - run: bun run test:backend - - plugins-ci: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Lint plugins - run: bun run lint:plugins - - name: Test plugins - run: bun run test:plugins - - typecheck: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Generate embedded assets - run: bun run build:embeds - - name: TypeScript type check - run: bun run typecheck - - name: Knip (unused exports/imports) - run: bun run knip - - coverage: - runs-on: ubuntu-latest - needs: [app-ci, backend-ci, plugins-ci] - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Run tests with coverage - run: bun test packages --coverage --coverage-reporter=lcov - - name: Upload to Codecov - uses: codecov/codecov-action@v5 - with: - files: backstage-server/coverage/lcov.info - flags: typescript - fail_ci_if_error: false - - build: - runs-on: ubuntu-latest - needs: [coverage, typecheck] - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - run: bun run build - - uses: actions/upload-artifact@v4 - with: - name: backstage-server - path: backstage-server/dist/backstage-server - retention-days: 7 - - e2e: - runs-on: ubuntu-latest - needs: [build] - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Download backstage-server binary - uses: actions/download-artifact@v4 - with: - name: backstage-server - path: backstage-server/dist - - name: Make binary executable - run: chmod +x dist/backstage-server - - name: Install Playwright browsers - run: bunx playwright install --with-deps chromium - - name: Run E2E tests - run: bun run test:e2e - env: - USE_BINARY: 'true' - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: backstage-server/playwright-report/ - retention-days: 7 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4b2013c..e91d582 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -14,7 +14,6 @@ on: - 'Cargo.lock' - 'docs/**' - '.github/workflows/docs.yml' - - 'backstage-server/package.json' - 'vscode-extension/package.json' - 'vscode-extension/src/webhook-server.ts' pull_request: @@ -36,10 +35,10 @@ jobs: lint-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 with: components: rustfmt, clippy @@ -53,14 +52,31 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Build UI dist + run: | + cd ui + bun install --frozen-lockfile + bun run build + DIST_SIZE=$(du -sb dist/ | cut -f1) + echo "UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB uncompressed)" + if [ "$DIST_SIZE" -gt 5242880 ]; then + echo "::error::UI dist exceeds 5MB uncompressed budget (${DIST_SIZE}B)" + exit 1 + fi + - name: Check formatting run: cargo fmt -- --check - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --locked --all-targets --all-features -- -D warnings - name: Tests - run: cargo test --all-features + run: cargo test --locked --all-features - name: cargo-deny uses: EmbarkStudios/cargo-deny-action@v2 @@ -74,10 +90,10 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 with: components: llvm-tools-preview @@ -92,7 +108,7 @@ jobs: restore-keys: ${{ runner.os }}-cargo-coverage- - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@v2 - name: Configure Git for tests run: | @@ -113,7 +129,7 @@ jobs: # Git integration test flags OPERATOR_GIT_TEST_ENABLED: 'true' OPERATOR_GIT_PUSH_ENABLED: 'true' - run: cargo llvm-cov --all-features --codecov --output-path codecov.json -- --test-threads=1 + run: cargo llvm-cov --locked --all-features --codecov --output-path codecov.json -- --test-threads=1 - name: Cleanup optest branches if: always() @@ -135,10 +151,10 @@ jobs: needs: lint-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux @@ -154,12 +170,12 @@ jobs: restore-keys: ${{ runner.os }}-cargo-launch- - name: Build operator - run: cargo build + run: cargo build --locked - name: Run launch integration tests env: OPERATOR_LAUNCH_TEST_ENABLED: 'true' - run: cargo test --test launch_integration -- --test-threads=1 --nocapture + run: cargo test --locked --test launch_integration -- --test-threads=1 --nocapture - name: Cleanup sessions if: always() @@ -171,10 +187,10 @@ jobs: needs: lint-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -187,12 +203,12 @@ jobs: restore-keys: ${{ runner.os }}-cargo-relay- - name: Build opr8r (provides relay subcommand) - run: cargo build --manifest-path opr8r/Cargo.toml + run: cargo build --locked --manifest-path opr8r/Cargo.toml - name: Run relay integration tests env: OPERATOR_RELAY_INTEGRATION_TEST_ENABLED: 'true' - run: cargo test --test relay_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test relay_integration -- --nocapture --test-threads=1 build: needs: lint-test @@ -203,26 +219,22 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - bun_target: bun-linux-x64 artifact_name: operator-linux-x86_64 - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - bun_target: bun-linux-arm64 artifact_name: operator-linux-arm64 - os: macos-14 target: aarch64-apple-darwin - bun_target: bun-darwin-arm64 artifact_name: operator-macos-arm64 - os: windows-latest target: x86_64-pc-windows-msvc - bun_target: bun-windows-x64 artifact_name: operator-windows-x86_64.exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 with: targets: ${{ matrix.target }} @@ -236,49 +248,22 @@ jobs: key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo- - # Check if backstage-server exists (Milestone 6) - - name: Check for backstage-server - id: backstage - shell: bash - run: | - if [ -d "backstage-server" ] && [ -f "backstage-server/package.json" ]; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - - # Install Bun (only if backstage-server exists) + # Install Bun (for embedded UI build) - name: Install Bun - if: steps.backstage.outputs.exists == 'true' uses: oven-sh/setup-bun@v2 with: bun-version: latest - # Install backstage-server dependencies - - name: Install backstage dependencies - if: steps.backstage.outputs.exists == 'true' + # Build embedded web UI for operator binary + - name: Build UI for embedding run: | - cd backstage-server - bun install + cd ui + bun install --frozen-lockfile + bun run build - # Build release first to create target/ directory + # Build release with embedded UI - name: Build release - run: cargo build --release --target ${{ matrix.target }} - - # Build backstage frontend and compile binary (after cargo build creates target/) - # This builds the React frontend, generates embedded assets, then compiles - - name: Build backstage binary - if: steps.backstage.outputs.exists == 'true' - shell: bash - run: | - cd backstage-server - # Build frontend, generate embeds, compile binary - bun run build - # Ensure target directory exists (belt-and-suspenders) - mkdir -p ../target - # Move binary to target directory with platform suffix - mv dist/backstage-server ../target/backstage-server-${{ matrix.bun_target }} - ls -la ../target/backstage-server-* + run: cargo build --locked --release --features embed-ui --target ${{ matrix.target }} - name: Rename binary shell: bash @@ -305,37 +290,12 @@ jobs: APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} run: scripts/notarize.sh "${{ matrix.artifact_name }}" - # PAUSED: codesign/notarize of macOS backstage-server binary is temporarily disabled. - # Re-enable by uncommenting the two steps below. - # - name: Codesign backstage-server binary - # if: matrix.os == 'macos-14' && steps.backstage.outputs.exists == 'true' - # env: - # APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # run: scripts/codesign.sh "target/backstage-server-${{ matrix.bun_target }}" - # - # - name: Notarize backstage-server binary - # if: matrix.os == 'macos-14' && steps.backstage.outputs.exists == 'true' - # env: - # APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} - # APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} - # APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} - # run: scripts/notarize.sh "target/backstage-server-${{ matrix.bun_target }}" - - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} - # Upload backstage binary artifact - - name: Upload backstage artifact - if: steps.backstage.outputs.exists == 'true' - uses: actions/upload-artifact@v4 - with: - name: backstage-server-${{ matrix.bun_target }} - path: target/backstage-server-${{ matrix.bun_target }} - build-opr8r: needs: lint-test if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -360,10 +320,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry uses: actions/cache@v4 @@ -377,7 +337,7 @@ jobs: opr8r-${{ matrix.artifact_name }}-cargo- - name: Build release binary - run: cargo build --release + run: cargo build --locked --release - name: Strip binary (Linux/macOS) if: runner.os != 'Windows' @@ -438,7 +398,7 @@ jobs: needs: [build, build-opr8r, sign-windows] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -470,7 +430,6 @@ jobs: - name: Update package.json versions run: | - jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' backstage-server/package.json > tmp.json && mv tmp.json backstage-server/package.json jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' vscode-extension/package.json > tmp.json && mv tmp.json vscode-extension/package.json - name: Update opr8r Cargo.toml version @@ -487,7 +446,6 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add VERSION Cargo.toml Cargo.lock docs/_config.yml \ - backstage-server/package.json \ vscode-extension/package.json \ vscode-extension/src/webhook-server.ts \ opr8r/Cargo.toml opr8r/Cargo.lock @@ -504,8 +462,6 @@ jobs: mkdir -p release-assets # Copy operator binaries find artifacts -type f -name 'operator-*' -exec cp {} release-assets/ \; - # Copy backstage-server binaries (if built) - find artifacts -type f -name 'backstage-server-*' -exec cp {} release-assets/ \; 2>/dev/null || true # Copy opr8r binaries find artifacts -type f -name 'opr8r-*' -exec cp {} release-assets/ \; chmod +x release-assets/* @@ -526,10 +482,6 @@ jobs: OP_LINUX_ARM=$(sha256sum release-assets/operator-linux-arm64 | cut -d' ' -f1) OP_LINUX_X86=$(sha256sum release-assets/operator-linux-x86_64 | cut -d' ' -f1) OP_WIN=$(sha256sum release-assets/operator-windows-x86_64.exe | cut -d' ' -f1) - BS_DARWIN=$(sha256sum release-assets/backstage-server-bun-darwin-arm64 | cut -d' ' -f1) - BS_LINUX_ARM=$(sha256sum release-assets/backstage-server-bun-linux-arm64 | cut -d' ' -f1) - BS_LINUX_X64=$(sha256sum release-assets/backstage-server-bun-linux-x64 | cut -d' ' -f1) - BS_WIN=$(sha256sum release-assets/backstage-server-bun-windows-x64 | cut -d' ' -f1) cat > docs/_data/checksums.yml << EOF # SHA256 checksums - AUTO-GENERATED BY CI @@ -540,12 +492,6 @@ jobs: linux_arm64: "${OP_LINUX_ARM}" linux_x86_64: "${OP_LINUX_X86}" windows_x86_64: "${OP_WIN}" - - backstage: - darwin_arm64: "${BS_DARWIN}" - linux_arm64: "${BS_LINUX_ARM}" - linux_x64: "${BS_LINUX_X64}" - windows_x64: "${BS_WIN}" EOF - name: Commit checksums diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 282567c..d69866b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,7 +7,7 @@ on: paths: - 'docs/**' - 'src/docs_gen/**' - - 'src/backstage/taxonomy.toml' + - 'src/taxonomy/taxonomy.toml' - 'src/templates/*.json' - '.github/workflows/docs.yml' workflow_dispatch: @@ -23,17 +23,18 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + BUNDLE_FROZEN: 'true' jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Install Rust toolchain for docs generation - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 # Cache cargo for faster builds - name: Cache cargo @@ -48,7 +49,7 @@ jobs: # Generate reference documentation from source-of-truth files - name: Generate reference docs - run: cargo run -- docs + run: cargo run --locked -- docs - name: Setup Ruby uses: ruby/setup-ruby@v1 @@ -57,6 +58,10 @@ jobs: bundler-cache: true working-directory: docs + - name: Audit gems + run: gem install bundler-audit && bundle-audit check --update + working-directory: docs + - name: Build Jekyll site run: bundle exec jekyll build working-directory: docs diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index 1e8de8d..f5caf09 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -74,10 +74,10 @@ jobs: extension: .exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -90,7 +90,7 @@ jobs: restore-keys: ${{ matrix.artifact }}-cargo- - name: Build release - run: cargo build --release + run: cargo build --locked --release - name: Upload artifact uses: actions/upload-artifact@v4 @@ -119,10 +119,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -135,7 +135,7 @@ jobs: restore-keys: opr8r-${{ matrix.artifact }}-cargo- - name: Build release - run: cargo build --release + run: cargo build --locked --release - name: Upload artifact uses: actions/upload-artifact@v4 @@ -167,7 +167,7 @@ jobs: opr8r_artifact: opr8r-macos-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install tmux (Linux) if: runner.os == 'Linux' @@ -178,7 +178,7 @@ jobs: run: brew install tmux - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -208,7 +208,7 @@ jobs: - name: Run launch integration tests env: OPERATOR_LAUNCH_TEST_ENABLED: 'true' - run: cargo test --test launch_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test launch_integration -- --nocapture --test-threads=1 - name: Cleanup tmux sessions if: always() @@ -234,10 +234,10 @@ jobs: opr8r_artifact: opr8r-linux-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -303,10 +303,10 @@ jobs: opr8r_artifact: opr8r-macos-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -336,7 +336,7 @@ jobs: - name: Run cmux integration tests (mock-based) env: OPERATOR_CMUX_TEST_ENABLED: 'true' - run: cargo test --test launch_integration_cmux -- --nocapture --test-threads=1 + run: cargo test --locked --test launch_integration_cmux -- --nocapture --test-threads=1 # ============================================================================ # VSCODE WRAPPER LAUNCH TESTS (Rust library-level, all platforms) @@ -356,10 +356,10 @@ jobs: artifact: operator-macos-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -383,7 +383,7 @@ jobs: - name: Run VS Code wrapper launch tests env: OPERATOR_VSCODE_TEST_ENABLED: 'true' - run: cargo test --test launch_integration_vscode -- --nocapture --test-threads=1 + run: cargo test --locked --test launch_integration_vscode -- --nocapture --test-threads=1 # ============================================================================ # REST API INTEGRATION TESTS (All platforms) @@ -408,10 +408,10 @@ jobs: extension: .exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -436,7 +436,7 @@ jobs: - name: Run REST API integration tests env: OPERATOR_REST_API_TEST_ENABLED: 'true' - run: cargo test --test rest_api_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test rest_api_integration -- --nocapture --test-threads=1 # ============================================================================ # OPR8R CLI INTEGRATION TESTS (All platforms) @@ -465,7 +465,7 @@ jobs: extension: .exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download operator binary uses: actions/download-artifact@v4 @@ -530,7 +530,7 @@ jobs: run: working-directory: vscode-extension steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 46cb518..3418186 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # Full history needed for git operations @@ -61,7 +61,7 @@ jobs: git remote set-head origin --auto || true - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -82,7 +82,7 @@ jobs: OPERATOR_JIRA_EMAIL: ${{ secrets.OPERATOR_JIRA_EMAIL }} OPERATOR_JIRA_API_KEY: ${{ secrets.OPERATOR_JIRA_API_KEY }} OPERATOR_JIRA_TEST_PROJECT: ${{ secrets.OPERATOR_JIRA_TEST_PROJECT }} - run: cargo test --test kanban_integration jira_tests -- --nocapture --test-threads=1 + run: cargo test --locked --test kanban_integration jira_tests -- --nocapture --test-threads=1 - name: Run Linear integration tests if: >- @@ -91,7 +91,7 @@ jobs: env: OPERATOR_LINEAR_API_KEY: ${{ secrets.OPERATOR_LINEAR_API_KEY }} OPERATOR_LINEAR_TEST_TEAM: ${{ secrets.OPERATOR_LINEAR_TEST_TEAM }} - run: cargo test --test kanban_integration linear_tests -- --nocapture --test-threads=1 + run: cargo test --locked --test kanban_integration linear_tests -- --nocapture --test-threads=1 - name: Run GitHub Projects integration tests if: >- @@ -100,7 +100,7 @@ jobs: env: OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} - run: cargo test --test kanban_integration github_tests -- --nocapture --test-threads=1 + run: cargo test --locked --test kanban_integration github_tests -- --nocapture --test-threads=1 - name: Run cross-provider tests env: @@ -112,14 +112,14 @@ jobs: OPERATOR_LINEAR_TEST_TEAM: ${{ secrets.OPERATOR_LINEAR_TEST_TEAM }} OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} - run: cargo test --test kanban_integration test_provider_interface_consistency -- --nocapture + run: cargo test --locked --test kanban_integration test_provider_interface_consistency -- --nocapture # Git Integration Tests - name: Run Git integration tests (read-only) if: github.event_name != 'workflow_dispatch' || inputs.run_git env: OPERATOR_GIT_TEST_ENABLED: 'true' - run: cargo test --test git_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test git_integration -- --nocapture --test-threads=1 # NOTE: Git push tests disabled in CI - require authenticated push access # Run locally with OPERATOR_GIT_PUSH_ENABLED=true to test push operations diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index 65bd712..641b19f 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -28,10 +28,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 with: components: rustfmt, clippy @@ -50,10 +50,10 @@ jobs: run: cargo fmt -- --check - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --locked --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test --all-features + run: cargo test --locked --all-features - name: cargo-deny uses: EmbarkStudios/cargo-deny-action@v2 @@ -71,10 +71,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 with: components: llvm-tools-preview @@ -90,10 +90,10 @@ jobs: opr8r-${{ runner.os }}-cargo-coverage- - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@v2 - name: Generate coverage - run: cargo llvm-cov --all-features --codecov --output-path codecov.json + run: cargo llvm-cov --locked --all-features --codecov --output-path codecov.json - name: Upload to Codecov uses: codecov/codecov-action@v5 @@ -128,10 +128,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry uses: actions/cache@v4 @@ -145,7 +145,7 @@ jobs: opr8r-${{ matrix.artifact }}-cargo- - name: Build release binary - run: cargo build --release + run: cargo build --locked --release - name: Strip binary (Linux/macOS) if: runner.os != 'Windows' diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 4acb356..87fb026 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -30,7 +30,7 @@ jobs: lint-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 @@ -96,10 +96,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry uses: actions/cache@v4 @@ -113,7 +113,7 @@ jobs: opr8r-${{ matrix.artifact }}-cargo- - name: Build release binary - run: cargo build --release + run: cargo build --locked --release - name: Strip binary (Linux/macOS) if: runner.os != 'Windows' @@ -162,7 +162,7 @@ jobs: opr8r_extension: .exe runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 @@ -209,7 +209,7 @@ jobs: run: working-directory: . # Override global default - publish job runs from repo root steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/zed-extension.yaml b/.github/workflows/zed-extension.yaml index 16fdc5a..fbbb126 100644 --- a/.github/workflows/zed-extension.yaml +++ b/.github/workflows/zed-extension.yaml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 with: targets: wasm32-wasip1 components: clippy, rustfmt @@ -41,10 +41,17 @@ jobs: run: cargo fmt -- --check - name: Lint - run: cargo clippy --target wasm32-wasip1 -- -D warnings + run: cargo clippy --locked --target wasm32-wasip1 -- -D warnings - name: Build - run: cargo build --release --target wasm32-wasip1 + run: cargo build --locked --release --target wasm32-wasip1 + + - name: cargo-deny + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: zed-extension/Cargo.toml + arguments: --all-features + command: check - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 834975f..8300859 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,12 @@ docs/_site/ # built vscode extension *.vsix +# built zed extension +*.wasm + # vscode-extension generated types (copied from bindings/) vscode-extension/src/generated/ vscode-extension/out/ vscode-extension/.vscode-test/ -test-output.txt \ No newline at end of file +test-output.txt +.tickets/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e5f422f --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=604800000 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..e74e070 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +rust 1.95 diff --git a/AI_POLICY.md b/AI_POLICY.md new file mode 100644 index 0000000..a0d61f6 --- /dev/null +++ b/AI_POLICY.md @@ -0,0 +1,26 @@ +# AI Policy + +Using AI (i.e., LLMs) as tools for coding is welcome. This project itself encourages AI assisted software development. +Still, A high bar is held for software development process used by humans and AI to develop all contributions to this project. +Moreover, the project maintainers remain responsible for any code that is published as part of a release. +Code Contributors are expected to be responsible for what they publish. + +**AI should not be used to generate comments when communicating with maintainers**. +Comments are expected to be written by humans. Comments that are believed to be written by AI may be hidden without notice. + +If you are opening an issue, you should be able to describe the problem in your own words. + +If you are opening a pull request, you are expected to be able to explain the proposed changes in your own words. +This includes the pull request body and responses to questions. +**Do not copy responses from the AI when replying to questions from maintainers.** + +This project requires a human in the loop who understands the work produced by AI. +The endeavor of this project is to give humans tools to direct AI to accomplish higher quality consistent work. +**Contributions made by autonomous agents may be closed, perhaps without notice, and will be prioritized appropriately.** + +If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. +It must be accompanied by human commentary explaining the relevance and implications of the context. Do not share long snippets of AI output. + +AI is useful when communicating as a non-native English speaker. +If you are using AI to edit your comments for this purpose, please take the time to ensure it reflects your own voice and ideas. +If using AI for translation, we recommend writing in your native language and including the AI translation in a separate quote block. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c1adbf5..c31122d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,6 @@ cargo run cd vscode-extension && npm run lint && npm run compile ``` -**backstage-server** (TypeScript/Bun): -```bash -cd backstage-server && bun run lint && bun run typecheck && bun test -``` - ### Test-Driven Development (TDD) This project follows TDD practices: @@ -206,7 +201,7 @@ Operator uses a schema-driven, code-derived documentation strategy to reduce mai | File | Generates | Purpose | |------|-----------|---------| -| `src/backstage/taxonomy.toml` | `docs/backstage/taxonomy.md` | 25 project Kinds across 5 tiers | +| `src/taxonomy/taxonomy.toml` | `docs/taxonomy/index.md` | 25 project Kinds across 5 tiers | | `src/schemas/issuetype_schema.json` | `docs/schemas/issuetype.md` | Issue type structure (key, mode, fields, steps) | | `src/schemas/ticket_metadata.schema.json` | `docs/schemas/metadata.md` | Ticket YAML frontmatter format | | `src/ui/keybindings.rs` | `docs/shortcuts/index.md` | Keyboard shortcuts by context | @@ -243,3 +238,25 @@ All generated files include a header warning: 2. Implement `name()`, `source()`, `output_path()`, and `generate()` 3. Register in `src/docs_gen/mod.rs` `generate_all()` function 4. Add to CLI match in `src/main.rs` `cmd_docs()` + +## Design & UI Consistency + +Operator presents one brand (terracotta + cornflower + cream over a green +scale) across **four rendering surfaces**. Keep them consistent by following the +rule that fits each surface — they are deliberately *not* all styled the same +way. Full details and swatches live in `docs/design-system/` (`/design-system/`). + +**Brand source of truth:** `docs/assets/css/tokens.css` — the only place the +brand hex values + dark-mode overrides are declared. Both web surfaces consume +it; never re-declare a brand color elsewhere. + +| Surface | Where | Rule | +|---------|-------|------| +| Docs site (Jekyll) | `docs/assets/css/main.css` | Links `tokens.css` (via `_includes/head.html`); style components with `var(--...)`, never raw hex. | +| Embedded SPA (Vite/React) | `ui/src/index.css` + `*.module.css` | Imports `tokens.css`; layers app-only semantic tokens (`--surface`, `--border`, `--danger`, …) on top. Components reference semantic tokens, not raw hex. | +| Ratatui TUI | `src/ui/*.rs` | Terminal can't render hex — match a **semantic role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). Reuse `color_for_key`/`glyph_for_key` from `src/templates/mod.rs`; don't re-hardcode issuetype/priority colors. | +| VS Code webview (MUI) | `vscode-extension/webview-ui/` | **Defer to the VS Code host theme** (`computeStyles.ts` → `createVSCodeTheme.ts`). Apply brand only as accents via `OPERATOR_BRAND`; never override the user's editor theme wholesale. | + +When adding or changing UI: change a brand color in `tokens.css` (web surfaces +follow automatically); reference semantic tokens in new web CSS; map a role to +ANSI in the TUI; and leave the webview deferring to the editor theme. diff --git a/Cargo.lock b/Cargo.lock index 774a55c..0d73424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,57 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "jsonrpcmsg", + "rmcp", + "rustc-hash", + "schemars 1.2.1", + "serde", + "serde_json", + "shell-words", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" @@ -46,9 +97,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -61,15 +112,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -96,9 +147,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -141,9 +192,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -166,7 +217,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -197,7 +248,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -213,9 +264,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -223,7 +274,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -254,9 +305,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -356,9 +407,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -394,11 +445,20 @@ dependencies = [ "piper", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -423,9 +483,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.50" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -445,9 +505,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -459,9 +519,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -469,9 +529,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -481,9 +541,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -493,15 +553,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" @@ -533,7 +593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" dependencies = [ "async-trait", - "convert_case", + "convert_case 0.6.0", "json5", "nom", "pathdiff", @@ -560,7 +620,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -574,6 +634,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -584,6 +653,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -648,7 +727,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "crossterm_winapi", "mio", "parking_lot", @@ -689,8 +768,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -707,24 +796,49 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -753,7 +867,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -769,6 +883,29 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -802,11 +939,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "objc2", ] @@ -838,9 +975,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -923,27 +1060,31 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" @@ -1000,27 +1141,66 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1037,9 +1217,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1048,22 +1228,23 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1071,7 +1252,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1087,9 +1267,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1107,11 +1287,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -1132,9 +1325,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1142,7 +1335,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1151,9 +1344,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.3.2" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" dependencies = [ "derive_builder", "log", @@ -1162,9 +1355,15 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1188,9 +1387,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1221,9 +1420,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1266,9 +1465,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1281,7 +1480,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1289,15 +1487,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1322,14 +1519,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1348,9 +1544,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1372,12 +1568,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1385,9 +1582,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1398,9 +1595,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1412,15 +1609,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1432,15 +1629,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1451,6 +1648,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1470,9 +1673,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1480,12 +1683,23 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1521,11 +1735,11 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1543,19 +1757,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.9" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -1574,16 +1778,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1599,6 +1805,16 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonrpcmsg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1611,11 +1827,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "libc", ] @@ -1625,21 +1841,25 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.10.0", "libc", - "redox_syscall 0.6.0", ] [[package]] @@ -1650,15 +1870,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1671,9 +1891,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru" @@ -1692,9 +1912,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", "objc2", @@ -1719,9 +1939,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -1766,9 +1986,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1778,9 +1998,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -1793,19 +2013,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "7.1.3" @@ -1822,7 +2029,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "filetime", "fsevent-sys", "inotify", @@ -1837,9 +2044,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ "futures-lite", "log", @@ -1860,9 +2067,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -1878,9 +2085,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-modular" @@ -1908,9 +2115,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1921,7 +2128,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "dispatch2", "objc2", ] @@ -1938,7 +2145,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -1947,9 +2154,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1959,15 +2166,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1985,15 +2191,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2003,8 +2209,9 @@ dependencies = [ [[package]] name = "operator" -version = "0.1.31" +version = "0.2.0" dependencies = [ + "agent-client-protocol", "anyhow", "async-trait", "axum", @@ -2014,12 +2221,14 @@ dependencies = [ "config", "crossterm", "dirs", + "flate2", "futures-util", "glob", "handlebars", "http-body-util", "lazy_static", "mac-notification-sys", + "mime_guess", "notify", "notify-rust", "once_cell", @@ -2027,14 +2236,15 @@ dependencies = [ "ratatui", "regex", "reqwest", - "schemars", + "rust-embed", + "schemars 1.2.1", "serde", "serde_json", "serde_yaml", "sha2", "sysinfo", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "toml", @@ -2046,6 +2256,7 @@ dependencies = [ "ts-rs", "tui-textarea", "utoipa", + "utoipa-axum", "utoipa-swagger-ui", "uuid", "which", @@ -2116,7 +2327,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -2127,6 +2338,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2141,9 +2358,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -2151,9 +2368,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -2161,9 +2378,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", @@ -2174,31 +2391,45 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2207,9 +2438,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polling" @@ -2221,15 +2452,15 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2249,20 +2480,30 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2290,7 +2531,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2298,9 +2539,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -2311,7 +2552,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2333,24 +2574,30 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -2368,9 +2615,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2381,7 +2628,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -2390,7 +2637,7 @@ dependencies = [ "itertools", "lru", "paste", - "strum", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -2398,9 +2645,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2422,16 +2669,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_syscall" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" -dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", ] [[package]] @@ -2440,7 +2678,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -2467,9 +2705,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2479,9 +2717,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2490,15 +2728,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -2548,12 +2786,47 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "ron" version = "0.8.1" @@ -2561,7 +2834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags 2.10.0", + "bitflags 2.11.1", "serde", "serde_derive", ] @@ -2612,9 +2885,18 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -2622,7 +2904,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2631,22 +2913,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -2658,9 +2940,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2685,9 +2967,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2700,18 +2982,30 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "schemars" -version = "1.2.0" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", @@ -2724,9 +3018,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -2742,12 +3036,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2755,14 +3049,20 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -2806,16 +3106,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2861,13 +3161,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -2894,6 +3226,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2923,24 +3261,25 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2950,12 +3289,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2982,7 +3321,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2998,17 +3346,35 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3051,12 +3417,12 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3077,21 +3443,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows 0.61.3", "windows-version", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3115,11 +3481,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3135,9 +3501,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -3195,9 +3561,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3205,9 +3571,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3220,9 +3586,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3237,9 +3603,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3279,12 +3645,13 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3313,9 +3680,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -3326,33 +3693,33 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -3363,9 +3730,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3379,21 +3746,21 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -3422,12 +3789,13 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", - "thiserror 2.0.17", + "symlink", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -3466,9 +3834,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -3497,7 +3865,7 @@ dependencies = [ "chrono", "lazy_static", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "ts-rs-macros", "uuid", ] @@ -3527,9 +3895,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -3539,13 +3907,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -3556,15 +3924,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -3589,6 +3957,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3603,9 +3977,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3627,21 +4001,34 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "utoipa-gen", ] +[[package]] +name = "utoipa-axum" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839e89ad0db7f9e8737dace8ff43c1ce0711d5e0d08cc1c9d31cc8454d4643ee" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + [[package]] name = "utoipa-gen" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", @@ -3670,11 +4057,11 @@ dependencies = [ [[package]] name = "uuid" -version = "1.19.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -3725,18 +4112,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -3747,22 +4143,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3770,9 +4163,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -3783,18 +4176,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -3812,9 +4239,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -3827,7 +4254,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.1.2", + "rustix 1.1.4", "winsafe", ] @@ -4316,9 +4743,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4331,15 +4767,103 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yaml-rust2" @@ -4354,9 +4878,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4365,9 +4889,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -4377,9 +4901,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -4395,15 +4919,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix 1.1.4", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -4411,9 +4936,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4426,30 +4951,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "static_assertions", - "winnow", + "winnow 1.0.3", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4458,18 +4982,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -4485,9 +5009,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4496,9 +5020,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4507,9 +5031,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -4527,12 +5051,18 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "zopfli", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.3" @@ -4547,23 +5077,23 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4574,13 +5104,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 34b8698..dd837c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "operator" -version = "0.1.31" +version = "0.2.0" edition = "2021" +rust-version = "1.95" description = "Multi-agent orchestration dashboard for gbqr.us" authors = ["gbqr.us"] license = "MIT" @@ -91,9 +92,22 @@ http-body-util = "0.1" utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-swagger-ui = { version = "8", features = ["axum"] } +# Agent Client Protocol (ACP) — JSON-RPC 2.0 over stdio for editor integration +agent-client-protocol = "0.12" + +# Embedded web UI (behind embed-ui feature flag) +rust-embed = { version = "8", optional = true } +mime_guess = { version = "2", optional = true } +utoipa-axum = "0.1" + +[features] +default = ["embed-ui"] +embed-ui = ["dep:rust-embed", "dep:mime_guess"] + [dev-dependencies] operator-relay = { path = "crates/relay" } tempfile = "3" +flate2 = "1" [lints.rust] unsafe_code = "deny" diff --git a/README.md b/README.md index b02c24d..9522972 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ ![Operator! logo](docs/assets/img/operator_logo.svg) # Operator! -[![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/untra.operator-terminals?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals) +[![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) + +**_This Project is currently in alpha, is free to use, and officially promises nothing yet!_** * **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) +* **Editor** [![VS Code](https://img.shields.io/badge/VS_Code-007ACC)](https://operator.untra.io/getting-started/sessions/vscode/) [![Zed](https://img.shields.io/badge/Zed-084CCF?logo=zedindustries&logoColor=white)](https://operator.untra.io/getting-started/sessions/zed/) + * **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) * **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) * **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) +* **Platform** [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) + An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. Install Operator! Terminals extension from Visual Studio Code Marketplace @@ -307,7 +313,7 @@ Reference documentation is auto-generated from source-of-truth files to minimize | Generator | Source | Output | |-----------|--------|--------| -| taxonomy | `src/backstage/taxonomy.toml` | `docs/backstage/taxonomy.md` | +| taxonomy | `src/taxonomy/taxonomy.toml` | `docs/taxonomy/index.md` | | issuetype-schema | `src/schemas/issuetype_schema.json` | `docs/schemas/issuetype.md` | | metadata-schema | `src/schemas/ticket_metadata.schema.json` | `docs/schemas/metadata.md` | | shortcuts | `src/ui/keybindings.rs` | `docs/shortcuts/index.md` | diff --git a/VERSION b/VERSION index db7a480..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.31 +0.2.0 diff --git a/backstage-server/bunfig.toml b/backstage-server/bunfig.toml index ef9c844..d9661d4 100644 --- a/backstage-server/bunfig.toml +++ b/backstage-server/bunfig.toml @@ -1,2 +1,5 @@ +[install] +minimumReleaseAge = 604800 + [test] preload = ["./packages/app/src/test/setup.ts"] diff --git a/backstage-server/package.json b/backstage-server/package.json index 6edd3ef..ad71d62 100644 --- a/backstage-server/package.json +++ b/backstage-server/package.json @@ -1,6 +1,6 @@ { "name": "operator-backstage", - "version": "0.1.31", + "version": "0.2.0", "author": { "name": "Samuel Volin", "email": "untra.sam@gmail.com", diff --git a/bindings/AcpConfig.ts b/bindings/AcpConfig.ts new file mode 100644 index 0000000..b8a2a54 --- /dev/null +++ b/bindings/AcpConfig.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Agent Client Protocol (ACP) agent configuration. + * + * Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`, + * Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session + * maps to an in-progress ACP ticket and a delegator subprocess. + */ +export type AcpConfig = { +/** + * Whether the dashboard advertises the `operator acp` stdio entrypoint + * (and editor-config snippet actions). Set to false on machines that + * shouldn't be used as ACP agents. + */ +stdio_advertised: boolean, +/** + * Name of the delegator (from `[[delegators]]`) to use for ACP prompts. + * If unset or not found, falls back to the operator's default delegator + * resolution. + */ +default_delegator: string | null, +/** + * Maximum number of concurrent ACP sessions. New `session/new` requests + * beyond this limit are rejected with a JSON-RPC error. + */ +max_concurrent_sessions: number, }; diff --git a/bindings/AgentDetailResponse.ts b/bindings/AgentDetailResponse.ts new file mode 100644 index 0000000..ce288b6 --- /dev/null +++ b/bindings/AgentDetailResponse.ts @@ -0,0 +1,78 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Full details for a single agent + */ +export type AgentDetailResponse = { +/** + * Agent ID (UUID) + */ +id: string, +/** + * Associated ticket ID (e.g., "FEAT-042") + */ +ticket_id: string, +/** + * Ticket type: FEAT, FIX, INV, SPIKE + */ +ticket_type: string, +/** + * Project being worked on + */ +project: string, +/** + * Agent status: running, `awaiting_input`, completing, orphaned + */ +status: string, +/** + * When the agent started (ISO 8601) + */ +started_at: string, +/** + * Last activity timestamp (ISO 8601) + */ +last_activity: string, +/** + * Current workflow step + */ +current_step: string | null, +/** + * LLM tool used (e.g., "claude", "gemini", "codex") + */ +llm_tool: string | null, +/** + * LLM model alias (e.g., "opus", "sonnet", "gpt-4o") + */ +llm_model: string | null, +/** + * Launch mode: "default", "yolo", "docker", "docker-yolo" + */ +launch_mode: string | null, +/** + * PR URL if created during "pr" step + */ +pr_url: string | null, +/** + * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + */ +pr_status: string | null, +/** + * Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + */ +session_wrapper: string | null, +/** + * Review state for `awaiting_input` agents + */ +review_state: string | null, +/** + * Completed steps for this ticket + */ +completed_steps: Array, +/** + * Path to the git worktree for this ticket + */ +worktree_path: string | null, +/** + * Whether this is a paired (interactive) agent + */ +paired: boolean, }; diff --git a/bindings/AgentsConfig.ts b/bindings/AgentsConfig.ts index da77acf..475049d 100644 --- a/bindings/AgentsConfig.ts +++ b/bindings/AgentsConfig.ts @@ -1,6 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AgentsConfig = { max_parallel: number, cores_reserved: number, health_check_interval: bigint, +export type AgentsConfig = { max_parallel: number, cores_reserved: number, +/** + * Maximum concurrent agents per project/repo (default: 1). + * Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + */ +max_agents_per_repo: number, health_check_interval: bigint, /** * Timeout in seconds for each agent generation (default: 300 = 5 min) */ diff --git a/bindings/Config.ts b/bindings/Config.ts index c5a3837..eed1042 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -1,13 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AcpConfig } from "./AcpConfig"; import type { AgentsConfig } from "./AgentsConfig"; import type { ApiConfig } from "./ApiConfig"; -import type { BackstageConfig } from "./BackstageConfig"; import type { Delegator } from "./Delegator"; import type { GitConfig } from "./GitConfig"; import type { KanbanConfig } from "./KanbanConfig"; import type { LaunchConfig } from "./LaunchConfig"; import type { LlmToolsConfig } from "./LlmToolsConfig"; import type { LoggingConfig } from "./LoggingConfig"; +import type { McpConfig } from "./McpConfig"; import type { ModelServer } from "./ModelServer"; import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; @@ -28,7 +29,7 @@ projects: Array, agents: AgentsConfig, notifications: NotificationsConfi /** * Session wrapper configuration (tmux, vscode, or cmux) */ -sessions: SessionsConfig, llm_tools: LlmToolsConfig, backstage: BackstageConfig, rest_api: RestApiConfig, git: GitConfig, +sessions: SessionsConfig, llm_tools: LlmToolsConfig, rest_api: RestApiConfig, git: GitConfig, /** * Kanban provider configuration for syncing issues from Jira, Linear, etc. */ @@ -49,4 +50,12 @@ model_servers: Array, /** * Relay MCP injection configuration */ -relay: RelayConfig, }; +relay: RelayConfig, +/** + * Model Context Protocol (MCP) server configuration + */ +mcp: McpConfig, +/** + * Agent Client Protocol (ACP) agent configuration + */ +acp: AcpConfig, }; diff --git a/bindings/ExternalMcpServer.ts b/bindings/ExternalMcpServer.ts new file mode 100644 index 0000000..9fe22b4 --- /dev/null +++ b/bindings/ExternalMcpServer.ts @@ -0,0 +1,44 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An external MCP server to inject into spawned agent sessions. + * + * Values in `command`, `args`, and `env` support `${VAR}` interpolation, + * expanded at spawn time from the operator process environment. + * + * When `discover_from` is set, operator reads an MCP server spec from that + * JSON sidecar file at spawn time. The sidecar must contain a top-level + * `mcpServer` object with `command`, `args`, and `env` fields. If the file + * is absent and `command` is empty, the server is silently skipped. + */ +export type ExternalMcpServer = { +/** + * Server name used as the key in the `mcpServers` JSON object + * (e.g., "kanbots"). Must be unique across all external servers. + */ +name: string, +/** + * Command to execute. Supports `${VAR}` interpolation. + */ +command: string, +/** + * Command arguments. Each element supports `${VAR}` interpolation. + */ +args: Array, +/** + * Environment variables passed to the MCP server process. + * Values support `${VAR}` interpolation. + */ +env: { [key in string]?: string }, +/** + * Whether this server is enabled. Allows disabling without removing config. + */ +enabled: boolean, +/** + * Path to a JSON sidecar discovery file. Relative paths resolve from + * the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. + * When the file exists, its `mcpServer` spec is used verbatim (overriding + * `command`/`args`/`env`). When absent and `command` is empty, the server + * is silently skipped. + */ +discover_from: string | null, }; diff --git a/bindings/McpConfig.ts b/bindings/McpConfig.ts new file mode 100644 index 0000000..4fb7e6f --- /dev/null +++ b/bindings/McpConfig.ts @@ -0,0 +1,29 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExternalMcpServer } from "./ExternalMcpServer"; + +/** + * Model Context Protocol (MCP) server configuration + */ +export type McpConfig = { +/** + * Whether to mount MCP HTTP/SSE endpoints on the REST API server. + * Toggling requires an API restart (no hot-swap of the axum router). + */ +http_enabled: boolean, +/** + * Whether the descriptor endpoint advertises the `operator mcp` stdio + * command. Set to false on multi-tenant/remote deployments where clients + * shouldn't spawn local subprocesses. + */ +stdio_advertised: boolean, +/** + * Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + * create) over MCP. Defaults to `false` because any MCP client can call them. + */ +expose_ticket_write_tools: boolean, +/** + * External MCP servers to inject into spawned agent sessions. + * Each entry produces a separate `--mcp-config` file alongside the + * relay config when launching Claude Code agents. + */ +external_servers: Array, }; diff --git a/bindings/McpDescriptorResponse.ts b/bindings/McpDescriptorResponse.ts index 0e94b17..650cea8 100644 --- a/bindings/McpDescriptorResponse.ts +++ b/bindings/McpDescriptorResponse.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { StdioCommand } from "./StdioCommand"; /** * MCP server descriptor for client discovery @@ -27,4 +28,9 @@ label: string, /** * URL of the OpenAPI spec for reference */ -openapi_url: string | null, }; +openapi_url: string | null, +/** + * Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`. + * Clients may spawn this as a subprocess instead of using `transport_url`. + */ +stdio: StdioCommand | null, }; diff --git a/bindings/Project.ts b/bindings/Project.ts index a256a46..53853a7 100644 --- a/bindings/Project.ts +++ b/bindings/Project.ts @@ -34,7 +34,7 @@ default_branch: string | null, */ ai_context_path: string | null, /** - * Backstage taxonomy kind (tier 1-5) + * Project taxonomy kind (tier 1-5) */ kind: string | null, /** diff --git a/bindings/SectionDto.ts b/bindings/SectionDto.ts new file mode 100644 index 0000000..78af2f5 --- /dev/null +++ b/bindings/SectionDto.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SectionRowDto } from "./SectionRowDto"; + +/** + * A status section with its health and child rows. + */ +export type SectionDto = { +/** + * Stable section id (e.g. "config", "connections", "kanban"). + */ +id: string, label: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, description: string, +/** + * Section ids that must be Green before this section is usable. + */ +prerequisites: Array, +/** + * Whether all prerequisites are met. Sections are always returned (the web + * UI styles unmet ones as locked) rather than hidden by progressive disclosure. + */ +met: boolean, children: Array, }; diff --git a/bindings/SectionRowDto.ts b/bindings/SectionRowDto.ts new file mode 100644 index 0000000..c720dea --- /dev/null +++ b/bindings/SectionRowDto.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A child row within a status section. + */ +export type SectionRowDto = { +/** + * Stable, section-scoped row id. Clients use it as a tree key and to route + * row-specific commands without matching on the (mutable) display label. + * Dynamic rows carry their entity key (issue-type key, project name); + * static rows carry a fixed slug (e.g. "git-token"). + */ +id: string, +/** + * Nesting depth within the section (1 = direct child, 2 = grandchild). + * Lets clients rebuild the tree (e.g. LLM tools → model aliases). + */ +depth: number, label: string, description: string, +/** + * Icon hint (e.g. "check", "warning", "tool", "folder"). + */ +icon: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, }; diff --git a/bindings/StdioCommand.ts b/bindings/StdioCommand.ts new file mode 100644 index 0000000..0e4e894 --- /dev/null +++ b/bindings/StdioCommand.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stdio entrypoint advertised in the descriptor when + * `[mcp].stdio_advertised = true`. Clients use this to spawn operator + * as an MCP subprocess instead of (or alongside) the SSE transport. + */ +export type StdioCommand = { +/** + * Absolute path to the operator binary (the same binary serving this descriptor) + */ +command: string, +/** + * Args to pass: typically `["mcp"]` + */ +args: Array, +/** + * Working directory the client should set when spawning. Defaults to the + * operator process's current working directory. + */ +cwd: string, }; diff --git a/bindings/TicketDetailResponse.ts b/bindings/TicketDetailResponse.ts new file mode 100644 index 0000000..5153b1b --- /dev/null +++ b/bindings/TicketDetailResponse.ts @@ -0,0 +1,82 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Full ticket details including content and metadata + */ +export type TicketDetailResponse = { +/** + * Ticket ID (e.g., "FEAT-7598") + */ +id: string, +/** + * Ticket summary/title + */ +summary: string, +/** + * Ticket type: FEAT, FIX, INV, SPIKE + */ +ticket_type: string, +/** + * Project name + */ +project: string, +/** + * Current status: queued, running, awaiting, completed + */ +status: string, +/** + * Current step name + */ +step: string, +/** + * Human-readable step name + */ +step_display_name: string | null, +/** + * Priority: P0-critical, P1-high, P2-medium, P3-low + */ +priority: string, +/** + * Timestamp (YYYYMMDD-HHMM format) + */ +timestamp: string, +/** + * Full markdown content of the ticket + */ +content: string, +/** + * Ticket filename + */ +filename: string, +/** + * Full filesystem path + */ +filepath: string, +/** + * Session IDs per step (`step_name` -> `session_uuid`) + */ +sessions: { [key in string]?: string }, +/** + * Delegator used per step (`step_name` -> `delegator_name`) + */ +step_delegators: { [key in string]?: string }, +/** + * Path to git worktree (if created) + */ +worktree_path: string | null, +/** + * Git branch name + */ +branch: string | null, +/** + * External issue ID from kanban provider + */ +external_id: string | null, +/** + * URL to the issue in the external provider + */ +external_url: string | null, +/** + * Provider name (e.g., "jira", "linear") + */ +external_provider: string | null, }; diff --git a/bindings/UpdateTicketStatusRequest.ts b/bindings/UpdateTicketStatusRequest.ts new file mode 100644 index 0000000..5dcf018 --- /dev/null +++ b/bindings/UpdateTicketStatusRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to update a ticket's status + */ +export type UpdateTicketStatusRequest = { +/** + * Target status: queued, running, awaiting, done + */ +status: string, }; diff --git a/bindings/UpdateTicketStatusResponse.ts b/bindings/UpdateTicketStatusResponse.ts new file mode 100644 index 0000000..368d1e4 --- /dev/null +++ b/bindings/UpdateTicketStatusResponse.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response from updating a ticket's status + */ +export type UpdateTicketStatusResponse = { +/** + * Ticket ID + */ +id: string, +/** + * Previous status before the update + */ +previous_status: string, +/** + * New status after the update + */ +status: string, +/** + * Human-readable message + */ +message: string, }; diff --git a/bindings/WorkflowExportResponse.ts b/bindings/WorkflowExportResponse.ts new file mode 100644 index 0000000..5f53777 --- /dev/null +++ b/bindings/WorkflowExportResponse.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response for exporting a ticket to a Claude dynamic workflow (`.js`). + */ +export type WorkflowExportResponse = { +/** + * The ticket the workflow was generated from. + */ +ticket_id: string, +/** + * The issue type key that supplied the step structure. + */ +issuetype_key: string, +/** + * Suggested filename for saving the workflow (`.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source. + */ +contents: string, }; diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..df59344 --- /dev/null +++ b/build.rs @@ -0,0 +1,50 @@ +use std::path::Path; + +fn main() { + if std::env::var("CARGO_FEATURE_EMBED_UI").is_err() { + return; + } + + let ui_dist = Path::new("ui/dist"); + let index = ui_dist.join("index.html"); + + // Create a minimal placeholder so rust-embed's derive macro doesn't fail + // during CI `--all-features` when the UI hasn't been built yet. + if !index.exists() { + std::fs::create_dir_all(ui_dist).expect("create ui/dist"); + std::fs::write( + &index, + "", + ) + .expect("write placeholder index.html"); + println!( + "cargo:warning=ui/dist/index.html is a placeholder — run `cd ui && bun run build` for real UI" + ); + } + + // Size gate: walk ui/dist, sum file sizes, fail if over 15MB uncompressed + let total = walk_dir_size(ui_dist); + assert!( + total <= 15_728_640, + "UI dist is {}B ({:.1}MB) — exceeds 15MB uncompressed budget", + total, + total as f64 / 1_048_576.0 + ); + + println!("cargo:rerun-if-changed=ui/dist"); +} + +fn walk_dir_size(dir: &Path) -> u64 { + let mut total = 0u64; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += walk_dir_size(&path); + } else if let Ok(meta) = path.metadata() { + total += meta.len(); + } + } + } + total +} diff --git a/coder-module/README.md b/coder-module/README.md new file mode 100644 index 0000000..4c92746 --- /dev/null +++ b/coder-module/README.md @@ -0,0 +1,77 @@ +--- +display_name: Operator +description: Run Operator agent orchestrator as a background service in your Coder workspace +icon: ../../../../.icons/terminal.svg +verified: false +tags: [ai, agents, orchestration, automation] +--- + +# Operator + +Run [Operator](https://github.com/untra/operator) as a background REST API server inside your Coder workspace. Operator manages ticket queues, launches LLM-powered coding agents, and tracks their progress. + +The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. + +## Usage + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +### Custom configuration + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + port = 7008 + max_parallel_agents = 4 + session_wrapper = "tmux" +} +``` + +### Full TOML override + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + config_toml = <<-EOT + [rest_api] + enabled = true + port = 7008 + + [agents] + max_parallel = 4 + health_check_interval = 30 + + [sessions] + wrapper = "tmux" + + [[delegators]] + name = "default" + tool = "claude-code" + model = "sonnet" + EOT +} +``` + +## Prerequisites + +The workspace image must include `tmux` (or your chosen `session_wrapper`) for operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +## Coder Workspace Context + +Coder automatically injects environment variables into every workspace that operator can reference in ticket templates and agent prompts: + +- `CODER_WORKSPACE_NAME` — workspace identifier +- `CODER_WORKSPACE_OWNER` — workspace owner username +- `CODER_AGENT_TOKEN` — agent authentication token + +No operator configuration is needed to access these — they are ambient in the workspace environment. diff --git a/coder-module/main.test.ts b/coder-module/main.test.ts new file mode 100644 index 0000000..3e7a1e9 --- /dev/null +++ b/coder-module/main.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("operator", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("offline and use_cached cannot both be true", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + offline: "true", + use_cached: "true", + }); + }; + expect(t).toThrow("offline and use_cached cannot both be true"); + }); + + it("rejects invalid session_wrapper values", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + session_wrapper: "invalid", + }); + }; + expect(t).toThrow("session_wrapper must be one of"); + }); + + it("rejects invalid share values", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + share: "invalid", + }); + }; + expect(t).toThrow("share must be one of"); + }); + + it("applies with default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const script = findResourceInstance(state, "coder_script"); + expect(script.run_on_start).toBe(true); + expect(script.display_name).toBe("Operator"); + + const app = findResourceInstance(state, "coder_app"); + expect(app.url).toBe("http://localhost:7008"); + expect(app.slug).toBe("operator"); + expect(app.display_name).toBe("Operator"); + expect(app.share).toBe("owner"); + expect(app.healthcheck[0].url).toBe( + "http://localhost:7008/api/v1/health", + ); + }); + + it("applies with custom port", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + port: "9000", + }); + + const app = findResourceInstance(state, "coder_app"); + expect(app.url).toBe("http://localhost:9000"); + expect(app.healthcheck[0].url).toBe( + "http://localhost:9000/api/v1/health", + ); + }); + + it("generates config with custom values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + port: "9000", + max_parallel_agents: "4", + session_wrapper: "zellij", + }); + + const script = findResourceInstance(state, "coder_script").script; + expect(script).toContain("port = 9000"); + expect(script).toContain("max_parallel = 4"); + expect(script).toContain('wrapper = "zellij"'); + }); + + it("uses config_toml verbatim when provided", async () => { + const customConfig = '[rest_api]\nenabled = true\nport = 8080'; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + config_toml: customConfig, + }); + + const script = findResourceInstance(state, "coder_script").script; + expect(script).toContain(customConfig); + }); +}); diff --git a/coder-module/main.tf b/coder-module/main.tf new file mode 100644 index 0000000..3e788a0 --- /dev/null +++ b/coder-module/main.tf @@ -0,0 +1,149 @@ +terraform { + required_version = ">= 1.2" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port for the operator REST API server." + default = 7008 +} + +variable "display_name" { + type = string + description = "The display name for the operator application in the Coder dashboard." + default = "Operator" +} + +variable "slug" { + type = string + description = "The slug for the operator application." + default = "operator" +} + +variable "install_version" { + type = string + description = "The version of operator to install (must match a GitHub release tag)." + default = "0.2.0" +} + +variable "install_prefix" { + type = string + description = "The directory to install the operator binary into." + default = "/tmp/operator" +} + +variable "log_path" { + type = string + description = "The path to write operator log output." + default = "/tmp/operator.log" +} + +variable "config_toml" { + type = string + description = "Raw TOML configuration content. When set, this is written verbatim to .tickets/operator/config.toml instead of the auto-generated config." + default = "" +} + +variable "max_parallel_agents" { + type = number + description = "Maximum number of parallel agents operator can run." + default = 2 +} + +variable "session_wrapper" { + type = string + description = "Session wrapper type for agent terminal sessions." + default = "tmux" + validation { + condition = contains(["tmux", "cmux", "zellij"], var.session_wrapper) + error_message = "session_wrapper must be one of: tmux, cmux, zellij." + } +} + +variable "share" { + type = string + default = "owner" + validation { + condition = contains(["owner", "authenticated", "public"], var.share) + error_message = "share must be one of: owner, authenticated, public." + } +} + +variable "order" { + type = number + description = "The order determines the position of the app in the Coder dashboard. Lower values appear first." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "offline" { + type = bool + description = "Skip downloading operator from GitHub. Requires a pre-installed binary at install_prefix." + default = false +} + +variable "use_cached" { + type = bool + description = "Use a cached operator binary if present, otherwise download from GitHub." + default = false +} + +resource "coder_script" "operator" { + agent_id = var.agent_id + display_name = "Operator" + icon = "/icon/terminal.svg" + script = templatefile("${path.module}/run.sh", { + VERSION = var.install_version, + PORT = var.port, + INSTALL_PREFIX = var.install_prefix, + LOG_PATH = var.log_path, + CONFIG_TOML = var.config_toml, + MAX_PARALLEL = var.max_parallel_agents, + SESSION_WRAPPER = var.session_wrapper, + OFFLINE = var.offline, + USE_CACHED = var.use_cached, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || !var.use_cached + error_message = "offline and use_cached cannot both be true." + } + } +} + +resource "coder_app" "operator" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}" + icon = "/icon/terminal.svg" + subdomain = false + share = var.share + order = var.order + group = var.group + + healthcheck { + url = "http://localhost:${var.port}/api/v1/health" + interval = 5 + threshold = 6 + } +} diff --git a/coder-module/run.sh b/coder-module/run.sh new file mode 100755 index 0000000..4c793bf --- /dev/null +++ b/coder-module/run.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +RESET='\033[0m' + +ARCH=$$(uname -m) +case "$$ARCH" in + x86_64) PLATFORM="linux-x86_64" ;; + aarch64) PLATFORM="linux-arm64" ;; + *) + echo "Unsupported architecture: $$ARCH" + exit 1 + ;; +esac + +OPERATOR_BIN="${INSTALL_PREFIX}/operator" + +if [ "${USE_CACHED}" = "true" ] && [ -f "$$OPERATOR_BIN" ]; then + echo "Using cached operator binary" +elif [ "${OFFLINE}" = "true" ]; then + if [ -f "$$OPERATOR_BIN" ]; then + echo "Using offline operator binary" + else + echo "No operator binary found in offline mode" + exit 1 + fi +else + printf "$${BOLD}Installing operator v${VERSION}...$${RESET}\n" + + if [ -n "$$CODER_SCRIPT_BIN_DIR" ] && [ -e "$$CODER_SCRIPT_BIN_DIR/operator" ]; then + rm "$$CODER_SCRIPT_BIN_DIR/operator" + fi + + mkdir -p "${INSTALL_PREFIX}" + RELEASE_URL="https://github.com/untra/operator/releases/download/v${VERSION}/operator-$$PLATFORM" + + output=$$(curl -fsSL "$$RELEASE_URL" -o "$$OPERATOR_BIN" 2>&1) + if [ $$? -ne 0 ]; then + echo "Failed to download operator: $$output" + exit 1 + fi + chmod +x "$$OPERATOR_BIN" + printf "Operator v${VERSION} installed to ${INSTALL_PREFIX}\n" +fi + +if [ -n "$$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$$CODER_SCRIPT_BIN_DIR/operator" ]; then + ln -s "$$OPERATOR_BIN" "$$CODER_SCRIPT_BIN_DIR/operator" +fi + +mkdir -p .tickets/operator .tickets/queue + +if [ -n "${CONFIG_TOML}" ]; then + echo "${CONFIG_TOML}" > .tickets/operator/config.toml +else + cat > .tickets/operator/config.toml < "${LOG_PATH}" 2>&1 & + +for i in $$(seq 1 30); do + if curl -s "http://localhost:${PORT}/api/v1/health" > /dev/null 2>&1; then + echo "Operator is running on port ${PORT}" + exit 0 + fi + sleep 1 +done + +echo "Operator failed to start within 30 seconds. Check logs at ${LOG_PATH}" +exit 1 diff --git a/config/default.toml b/config/default.toml index 61ee22c..78516d9 100644 --- a/config/default.toml +++ b/config/default.toml @@ -10,6 +10,9 @@ max_parallel = 5 # Number of CPU cores to keep free (actual max = cores - reserved) cores_reserved = 1 +# Maximum concurrent agents per project/repo (requires use_worktrees when > 1) +max_agents_per_repo = 1 + # Timeout for agent health checks (seconds) health_check_interval = 30 diff --git a/crates/relay/Cargo.lock b/crates/relay/Cargo.lock index 767e158..91350b9 100644 --- a/crates/relay/Cargo.lock +++ b/crates/relay/Cargo.lock @@ -33,9 +33,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -94,13 +94,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -177,9 +176,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -200,7 +199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -242,9 +241,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -264,9 +263,9 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ "bitflags 2.11.1", "libc", @@ -290,10 +289,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall", ] [[package]] @@ -304,9 +300,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "memchr" @@ -388,12 +384,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "prettyplease" version = "0.2.37" @@ -428,15 +418,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -514,9 +495,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -587,9 +568,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -701,9 +682,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -714,9 +695,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -724,9 +705,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -737,9 +718,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 64f01ec..e2a3c2d 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) base64 (0.3.0) bigdecimal (4.0.1) @@ -45,13 +45,13 @@ GEM webrick (~> 1.7) jekyll-sass-converter (3.1.0) sass-embedded (~> 1.75) - jekyll-seo-tag (2.8.0) + jekyll-seo-tag (2.9.0) jekyll (>= 3.8, < 5.0) jekyll-sitemap (1.4.0) jekyll (>= 3.7, < 5.0) jekyll-watch (2.2.1) listen (~> 3.0) - json (2.18.0) + json (2.19.5) kramdown (2.5.1) rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) diff --git a/docs/_config.yml b/docs/_config.yml index 1715da0..6ea5623 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -42,7 +42,7 @@ collections_dir: . # Permalink structure permalink: pretty -version: 0.1.31 +version: 0.2.0 # Google Analytics ga_tag: G-5JZPJWWT7S # Replace with actual GA4 measurement ID from analytics.google.com diff --git a/docs/_data/checksums.yml b/docs/_data/checksums.yml index e3c5dc2..a227eeb 100644 --- a/docs/_data/checksums.yml +++ b/docs/_data/checksums.yml @@ -6,9 +6,3 @@ operator: linux_arm64: "8f88a7faaf536809707c525e71190f3bee70a5c6be5f83636abe90118f83620f" linux_x86_64: "f26ae3badf31a914c15e979696fa4a2850cc6a942475dbe7b4fdb56dbc483d5e" windows_x86_64: "ac832b8daa1c7aa790b3a2921d9a94c193a9764c4f969daa0520dbb52f5001bc" - -backstage: - darwin_arm64: "b253c7af7f78dd077cece5ef0e616574a65eb2e976b639bbf4e67156b5bbd850" - linux_arm64: "92f345b2c239964e71f5822c796ac04b4c3e9e0a291f4c66c269507dfda17549" - linux_x64: "bebbc806b4c0b19deaa8d3707c04e078cff9103e96aa86404aeac565f6c36b5b" - windows_x64: "" diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 44e422d..73043d7 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -24,6 +24,12 @@ docs: - title: VS Code Extension url: /getting-started/sessions/vscode/ icon: vscode + - title: Cursor + url: /getting-started/sessions/cursor/ + icon: cursor + - title: Zed + url: /getting-started/sessions/zed/ + icon: zed - title: Supported Kanban Providers url: /getting-started/kanban/ children: @@ -63,6 +69,12 @@ docs: - title: Operating System url: /getting-started/notifications/os/ icon: notification + - title: Supported Workspace Platforms + url: /getting-started/platforms/ + children: + - title: Coder + url: /getting-started/platforms/coder/ + icon: coder - title: Core children: - title: Kanban @@ -86,10 +98,12 @@ docs: url: /configuration/ - title: Shortcuts url: /shortcuts/ - - title: Backstage + - title: Design System + url: /design-system/ + - title: Taxonomy children: - title: Project Taxonomy - url: /backstage/taxonomy/ + url: /taxonomy/ - title: Schemas children: - title: Overview diff --git a/docs/_includes/head.html b/docs/_includes/head.html index da2637c..9c683f2 100644 --- a/docs/_includes/head.html +++ b/docs/_includes/head.html @@ -18,6 +18,7 @@ })(); + diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 83b0d41..d780bf4 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -1,13 +1,7 @@ /* Operator Documentation Theme - * Color Palette (Refined): - * - Terracotta: #E05D44 (primary, backgrounds) - * - Cornflower: #6688AA (muted text, separators) - * - Cream: #F2EAC9 (accent, highlights) - * Green Scale: - * - Sage: #66AA99 (L1 - nav buttons) - * - Teal: #448880 (L2 - hover) - * - Deep Pine: #115566 (L3 - selected, text) - * - Midnight: #082226 (L4 - darkest) + * Brand palette + green scale are defined in tokens.css (single source of + * truth, shared with the embedded SPA). This file holds docs-site layout and + * component styling only, referencing those tokens via var(--...). */ /* Brand styling for inline Operator! mentions */ @@ -16,42 +10,13 @@ font-weight: 700; } +/* Brand palette + green scale + dark overrides live in tokens.css + * (the single source of truth, linked before this file in _includes/head.html). + * Only docs-site-specific tokens belong here. */ :root { - --color-salmon: #E05D44; - --color-salmon-dark: #6688AA; - --color-cream: #F2EAC9; - --color-coral: #E05D44; - --color-bg: #faf8f5; - --color-white: #ffffff; - - /* Green scale */ - --color-green-l1: #66AA99; - --color-green-l2: #448880; - --color-green-l3: #115566; - --color-green-l4: #082226; - --color-teal: #115566; - - --sidebar-width: 260px; --content-max-width: 800px; } -/* Dark theme */ -[data-theme="dark"] { - --color-salmon: #E05D44; - --color-salmon-dark: #88AABB; - --color-cream: #1a1d1e; - --color-coral: #FF7A66; - --color-bg: #0d1011; - --color-white: #1a1d1e; - - /* Green scale - adjusted for dark mode */ - --color-green-l1: #3d8a7a; - --color-green-l2: #5aa896; - --color-green-l3: #88ccbb; - --color-green-l4: #aaddcc; - --color-teal: #b8d4cc; -} - /* Reset */ *, *::before, *::after { box-sizing: border-box; @@ -114,7 +79,7 @@ body { margin: 8px 0 0; font-size: 0.75rem; font-weight: 700; - color: var(--color-salmon-dark); + color: var(--color-cornflower); } .sidebar-nav { @@ -340,7 +305,7 @@ summary.nav-item-row::-webkit-details-marker { } .sidebar-nav .downloads-link a:hover { - background: var(--color-salmon-dark); + background: var(--color-cornflower); } .sidebar-nav .downloads-link a.active { @@ -350,11 +315,11 @@ summary.nav-item-row::-webkit-details-marker { .sidebar-footer { margin-top: 32px; padding-top: 16px; - border-top: 1px solid var(--color-salmon-dark); + border-top: 1px solid var(--color-cornflower); } .sidebar-footer a { - color: var(--color-salmon-dark); + color: var(--color-cornflower); text-decoration: none; font-size: 0.875rem; } @@ -448,13 +413,47 @@ blockquote { padding: 12px 20px; border-left: 4px solid var(--color-salmon); background-color: var(--color-cream); - color: var(--color-salmon-dark); + color: var(--color-cornflower); } blockquote p:last-child { margin-bottom: 0; } +/* Badges */ +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 600; +} + +.badge.supported { + background-color: var(--color-green-l1); + color: var(--color-green-l4); +} + +.badge.recommended { + background-color: var(--color-green-l2); + color: var(--color-cream); +} + +.badge.alpha { + background-color: var(--color-salmon); + color: var(--color-cream); +} + +/* Alpha integration banner */ +.alpha-banner { + margin: 0.5em 0 1.5em; + padding: 12px 20px; + border-left: 4px solid var(--color-salmon); + background-color: var(--color-cream); + color: var(--color-cornflower); + font-style: italic; +} + /* Tables */ table { width: 100%; @@ -501,7 +500,7 @@ tr:hover { } .download-button:hover { - background: var(--color-salmon-dark); + background: var(--color-cornflower); color: var(--color-cream); text-decoration: none; } diff --git a/docs/assets/css/tokens.css b/docs/assets/css/tokens.css new file mode 100644 index 0000000..c0f6c69 --- /dev/null +++ b/docs/assets/css/tokens.css @@ -0,0 +1,50 @@ +/* Operator! brand tokens — SINGLE SOURCE OF TRUTH for web surfaces. + * + * Consumed by both the docs site (docs/assets/css/main.css) and the embedded + * React SPA (ui/src/index.css). Do not re-declare these brand vars in either + * consumer — change them here and both surfaces follow. + * + * The TUI (src/ui/*.rs, ANSI) and the VS Code webview (defers to the editor + * theme) deliberately do NOT consume this file — see operator/CLAUDE.md + * "Design & UI Consistency" and docs/design-system/. + * + * Palette: warm terracotta + cornflower + cream, over a green scale + * (sage -> teal -> deep pine -> midnight). Light default, dark via + * [data-theme="dark"]. + */ + +:root { + /* Brand palette */ + --color-salmon: #e05d44; /* Terracotta — primary brand */ + --color-cornflower: #6688aa; /* Muted blue — secondary text / separators */ + --color-cream: #f2eac9; /* Warm accent / highlights */ + --color-coral: #e05d44; /* Link / accent (alias of salmon in light) */ + --color-bg: #faf8f5; /* Page background */ + --color-white: #ffffff; + + /* Green scale */ + --color-green-l1: #66aa99; /* Sage — nav buttons */ + --color-green-l2: #448880; /* Teal — hover / success */ + --color-green-l3: #115566; /* Deep pine — selected / primary text */ + --color-green-l4: #082226; /* Midnight — darkest */ + --color-teal: #115566; /* Body text (== green-l3) */ + + /* Shared layout */ + --sidebar-width: 260px; +} + +[data-theme="dark"] { + --color-salmon: #e05d44; + --color-cornflower: #88aabb; + --color-cream: #1a1d1e; + --color-coral: #ff7a66; + --color-bg: #0d1011; + --color-white: #1a1d1e; + + /* Green scale — adjusted for dark mode */ + --color-green-l1: #3d8a7a; + --color-green-l2: #5aa896; + --color-green-l3: #88ccbb; + --color-green-l4: #aaddcc; + --color-teal: #b8d4cc; +} diff --git a/docs/assets/icons/coder.svg b/docs/assets/icons/coder.svg new file mode 100644 index 0000000..a0acff3 --- /dev/null +++ b/docs/assets/icons/coder.svg @@ -0,0 +1 @@ +Coder \ No newline at end of file diff --git a/docs/assets/icons/cursor.svg b/docs/assets/icons/cursor.svg new file mode 100644 index 0000000..61303fb --- /dev/null +++ b/docs/assets/icons/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/docs/assets/icons/zed.svg b/docs/assets/icons/zed.svg new file mode 100644 index 0000000..02327fd --- /dev/null +++ b/docs/assets/icons/zed.svg @@ -0,0 +1 @@ +Zed Industries \ No newline at end of file diff --git a/docs/cli/index.md b/docs/cli/index.md index db57386..e521366 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -17,6 +17,7 @@ Operator provides both a TUI dashboard and CLI commands for queue management. | `-c, --config` | Config file path | | `-d, --debug` | Enable debug logging | | `-w, --web` | Start with web view enabled | +| `--ui` | Open the embedded web UI in a browser on launch | ## Commands @@ -105,6 +106,19 @@ Start the REST API server for issue type management | Argument/Option | Description | | --- | --- | | `-p, --port` | Port to listen on (default: 7008) | +| `--open` | Open the web UI in browser after server starts | + +### `mcp` + +Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, `JetBrains`, etc.) + +No additional arguments. + +### `acp` + +Run as an ACP agent over stdio (for use by Zed, `JetBrains`, Emacs `agent-shell`, etc.) + +No additional arguments. ### `setup` @@ -114,13 +128,18 @@ Initialize operator workspace (non-interactive by default) | --- | --- | | `-i, --interactive` | Launch TUI setup wizard instead of non-interactive setup | | `-C, --collection` | Collection preset: simple, dev-kanban, devops-kanban (default: simple) | -| `--backstage` | Enable backstage configuration | | `-f, --force` | Overwrite existing files | | `-w, --working-dir` | Working directory (parent of .tickets/) | | `-k, --kanban-provider` | Kanban provider to configure: jira, linear | | `-l, --llm-tool` | Preferred LLM tool: claude, codex, gemini | | `--skip-llm-detection` | Skip LLM tool detection | +### `workflow` + +Convert between operator issuetypes and other orchestration formats + +No additional arguments. + ## Environment Variables All configuration can be overridden via environment variables using the `OPERATOR_` prefix with `__` as the separator for nested config paths. @@ -155,8 +174,6 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_LAUNCH__MODE` | Agent launch mode (tmux or direct) | tmux | | `OPERATOR_LAUNCH__CONFIRM` | Require confirmation before launching agents | true | | `OPERATOR_TMUX__SESSION_PREFIX` | Prefix for tmux session names | operator | -| `OPERATOR_BACKSTAGE__PORT` | Port for the Backstage web server | 3000 | -| `OPERATOR_BACKSTAGE__AUTO_START` | Automatically start Backstage server with TUI | false | | `OPERATOR_LLM_TOOLS__ENABLED` | Enable LLM tool allowlist/denylist functionality | true | | `OPERATOR_LLM_TOOLS__ALLOWED` | Comma-separated list of allowed LLM tools (empty = all allowed) | | | `OPERATOR_LLM_TOOLS__DENIED` | Comma-separated list of denied LLM tools | | @@ -229,13 +246,6 @@ All configuration can be overridden via environment variables using the `OPERATO | --- | --- | --- | | `OPERATOR_TMUX__SESSION_PREFIX` | Prefix for tmux session names | operator | -### Backstage - -| Variable | Description | Default | -| --- | --- | --- | -| `OPERATOR_BACKSTAGE__PORT` | Port for the Backstage web server | 3000 | -| `OPERATOR_BACKSTAGE__AUTO_START` | Automatically start Backstage server with TUI | false | - ### LLM Tools | Variable | Description | Default | diff --git a/docs/configuration/index.md b/docs/configuration/index.md index ce02fc3..122f02d 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -24,7 +24,6 @@ Operator configuration is stored in `.tickets/operator/config.toml`. | `[api]` | External API integration settings | | `[logging]` | Log level and output configuration | | `[tmux]` | Tmux integration settings | -| `[backstage]` | Backstage server integration | | `[llm_tools]` | LLM CLI tool detection and providers | ## `[agents]` @@ -35,6 +34,7 @@ Agent lifecycle, parallelism, and health monitoring | --- | --- | --- | --- | | `max_parallel` * | `integer` | 5 | | | `cores_reserved` * | `integer` | 1 | | +| `max_agents_per_repo` | `integer` | - | Maximum concurrent agents per project/repo (default: 1). Requires `git.use_worktrees` = true when > 1 to avoid conflicts. | | `health_check_interval` * | `integer` | 30 | | | `generation_timeout_secs` | `integer` | 300 | Timeout in seconds for each agent generation (default: 300 = 5 min) | | `sync_interval` | `integer` | 60 | Interval in seconds between ticket-session syncs (default: 60) | @@ -133,22 +133,6 @@ Tmux integration settings | --- | --- | --- | --- | | `config_generated` | `boolean` | - | Whether custom tmux config has been generated | -## `[backstage]` - -Backstage server integration - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `enabled` | `boolean` | true | Whether Backstage integration is enabled | -| `display` | `boolean` | - | Whether to show Backstage in the Connections status section | -| `port` | `integer` | 7007 | Port for the Backstage server | -| `auto_start` | `boolean` | false | Auto-start Backstage server when TUI launches | -| `subpath` | `string` | backstage | Subdirectory within `state_path` for Backstage installation | -| `branding_subpath` | `string` | branding | Subdirectory within backstage path for branding customization | -| `release_url` | `string` | - | Base URL for downloading backstage-server binary | -| `local_binary_path` | `string` \| `null` | - | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` | -| `branding` | → `BrandingConfig` | - | Branding and theming configuration | - ## `[llm_tools]` LLM CLI tool detection and providers @@ -172,6 +156,7 @@ model_servers = [] [agents] max_parallel = 5 cores_reserved = 1 +max_agents_per_repo = 1 health_check_interval = 30 generation_timeout_secs = 300 sync_interval = 60 @@ -278,27 +263,6 @@ detection_complete = false [llm_tools.skill_directory_overrides] -[backstage] -enabled = true -display = false -port = 7007 -auto_start = false -subpath = "backstage" -branding_subpath = "branding" -release_url = "https://github.com/untra/operator/releases/latest/download" - -[backstage.branding] -app_title = "Operator Portal" -org_name = "Operator" -logo_path = "logo.svg" - -[backstage.branding.colors] -primary = "#cc6c55" -secondary = "#114145" -accent = "#f4dbb7" -warning = "#d46048" -muted = "#8a4a3a" - [rest_api] enabled = true port = 7008 @@ -330,6 +294,16 @@ timeout_secs = 3 [relay] auto_inject_mcp = false +[mcp] +http_enabled = true +stdio_advertised = true +expose_ticket_write_tools = false +external_servers = [] + +[acp] +stdio_advertised = true +max_concurrent_sessions = 8 + ``` ## Configuration Files @@ -351,5 +325,5 @@ Any configuration option can be overridden via environment variables. **Examples**: - `OPERATOR_AGENTS__MAX_PARALLEL=2` - `OPERATOR_LOGGING__LEVEL=debug` -- `OPERATOR_BACKSTAGE__PORT=8080` +- `OPERATOR_TMUX__ENABLED=true` diff --git a/docs/design-system/index.md b/docs/design-system/index.md new file mode 100644 index 0000000..78a4c86 --- /dev/null +++ b/docs/design-system/index.md @@ -0,0 +1,93 @@ +--- +title: "Design System" +description: "Operator's brand palette, design tokens, and the consistency rules each rendering surface follows." +layout: doc +--- + +Operator! presents one brand across four +rendering surfaces. This page is the human-readable companion to the brand +tokens — it explains *intent* the raw `:root` block can't, and records the rules +each surface follows so the look stays consistent as the codebase grows. + +## Source of truth + +All brand colors live in one file: **`docs/assets/css/tokens.css`**. It declares +the palette and the dark-mode overrides as CSS custom properties, and nothing +else re-declares them. Change a color there and both web surfaces follow. + +``` +docs/assets/css/tokens.css ← single source of truth (:root + [data-theme="dark"]) + ├─ docs site: linked in _includes/head.html, before main.css + └─ embedded SPA: @import "../../docs/assets/css/tokens.css" in ui/src/index.css +``` + +## Palette + +| Token | Light | Role | +|-------|-------|------| +| `--color-salmon` | `#e05d44` | Terracotta — primary brand, headings accents, CTAs | +| `--color-cornflower` | `#6688aa` | Muted blue — secondary/muted text, separators | +| `--color-cream` | `#f2eac9` | Warm accent / highlight surfaces | +| `--color-coral` | `#e05d44` | Links / accents (alias of salmon in light mode) | +| `--color-bg` | `#faf8f5` | Page background | +| `--color-white` | `#ffffff` | Base surface | +| `--color-green-l1` | `#66aa99` | Sage — nav buttons | +| `--color-green-l2` | `#448880` | Teal — hover / success | +| `--color-green-l3` | `#115566` | Deep pine — selected / primary text | +| `--color-green-l4` | `#082226` | Midnight — darkest | +| `--color-teal` | `#115566` | Body text (equals green-l3) | + +Dark mode (`[data-theme="dark"]`) keeps salmon constant, brightens coral, and +inverts the green scale. See `tokens.css` for the exact dark values. + +> **Naming note:** `--color-cornflower` was previously named +> `--color-salmon-dark`, which lied about its value (`#6688aa` is blue, not a +> dark salmon). It was renamed in lockstep across both web surfaces. + +## Semantic tokens (SPA only) + +The embedded SPA layers app-specific semantic tokens on top of the brand +palette, in `ui/src/index.css`. The docs site doesn't need these. Components +reference the semantic token, never the raw brand color: + +`--surface`, `--surface-alt`, `--border`, `--text`, `--text-muted`, +`--danger` / `--danger-bg`, `--warning` / `--warning-bg`, +`--success` / `--success-bg`, plus layout tokens `--radius-sm|--radius|--radius-lg` +and `--font-sans|--font-mono`. + +## The four surfaces + +Each surface gets the rule that fits it — they are deliberately not styled +identically. + +| Surface | Where | Rule | +|---------|-------|------| +| **Docs site** (Jekyll) | `docs/assets/css/main.css` | Links `tokens.css`; style with `var(--...)`, never raw hex. | +| **Embedded SPA** (Vite/React) | `ui/src/index.css` + `*.module.css` | Imports `tokens.css`; uses semantic tokens, never raw hex. | +| **Ratatui TUI** | `src/ui/*.rs` | Terminal can't render hex — map a semantic **role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). | +| **VS Code webview** (MUI) | `vscode-extension/webview-ui/` | Defers to the VS Code host theme; brand only as accents via `OPERATOR_BRAND`. Never overrides the editor theme. | + +## Issue type glyphs & colors + +Issue type color + glyph are defined once in the collection JSON schemas and +read through `color_for_key` / `glyph_for_key` in `src/templates/mod.rs`. Reuse +those helpers — do not re-hardcode the mapping in new UI. + +| Type | Glyph | Color | +|------|-------|-------| +| FEAT | `*` | green | +| FIX | `#` | magenta | +| TASK | `>` | cyan | +| SPIKE | `?` | blue | +| INV | `!` | yellow | +| ASSESS | `~` | magenta | +| SYNC | `@` | blue | +| INIT | `%` | green | + +## Priority colors + +Priority maps to a single role across surfaces: P0 → danger (red), P1 → warning +(yellow/gold), P2 → sage green, P3 → muted border. In the SPA these resolve to +`--danger` / `--warning` / `--color-green-l1` / `--border` +(`ui/src/components/KanbanBoard.module.css`); in the TUI to the nearest ANSI +(`src/ui/panels.rs`). diff --git a/docs/downloads/index.md b/docs/downloads/index.md index 87baa52..c28d8c7 100644 --- a/docs/downloads/index.md +++ b/docs/downloads/index.md @@ -1,6 +1,6 @@ --- title: "Downloads" -description: "Download Operator! binaries for macOS, Linux, and Windows, including the optional Backstage server." +description: "Download Operator! binaries for macOS, Linux, and Windows." layout: doc --- @@ -35,19 +35,6 @@ For headless servers, CI/CD pipelines, or advanced workflows, download the CLI b | Linux | x86_64 | [operator-linux-x86_64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-linux-x86_64)
sha256:{{ site.data.checksums.operator.linux_x86_64 }} | | Windows | x86_64 | [operator-windows-x86_64.exe]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-windows-x86_64.exe)
sha256:{{ site.data.checksums.operator.windows_x86_64 }} | -## Backstage Server - -Optional companion server for web-based project monitoring dashboard. - -> **Note:** macOS code signing and notarization for the Backstage server binary is temporarily paused. The `backstage-server-bun-darwin-arm64` build is **unsigned**, so macOS Gatekeeper may block it on first launch ("cannot verify developer"). To run it, either remove the quarantine attribute (`xattr -d com.apple.quarantine ./backstage-server-bun-darwin-arm64`) or allow it via **System Settings → Privacy & Security**. The main `operator` binary is still signed and notarized as normal. - -| Platform | Architecture | Download | -|----------|--------------|----------| -| macOS | ARM64 | [backstage-server-bun-darwin-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-darwin-arm64) *(unsigned)*
sha256:{{ site.data.checksums.backstage.darwin_arm64 }} | -| Linux | ARM64 | [backstage-server-bun-linux-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-arm64)
sha256:{{ site.data.checksums.backstage.linux_arm64 }} | -| Linux | x64 | [backstage-server-bun-linux-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-x64)
sha256:{{ site.data.checksums.backstage.linux_x64 }} | -| Windows | x64 | [backstage-server-bun-windows-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-windows-x64)
sha256:{{ site.data.checksums.backstage.windows_x64 }} | - ## All Releases [View all releases on GitHub]({{ site.github.repo }}/releases) diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md index 0132224..d52b60b 100644 --- a/docs/getting-started/platform-support.md +++ b/docs/getting-started/platform-support.md @@ -13,12 +13,12 @@ This page is the authoritative reference for what Operator supports on each oper | Feature | macOS | Linux | Windows | |---------|-------|-------|---------| | Session manager: VS Code Extension | ✅ | ✅ | ✅ Required | +| Session manager: Cursor | ✅ | ✅ | ✅ | | Session manager: tmux | ✅ | ✅ | ❌ | | Session manager: cmux | ✅ | ❌ | ❌ | | Session manager: Zellij | ✅ | ✅ | ❌ | | Relay hub (multi-agent) | ✅ | ✅ | ❌ | | `opr8r relay` subcommand | ✅ | ✅ | ❌ | -| Backstage Server | ✅ | ✅ | ❌ | | Native OS notifications | ✅ | ✅ | ⚠️ | | Kanban: Jira Cloud | ✅ | ✅ | ✅ | | Kanban: Linear | ✅ | ✅ | ✅ | @@ -40,7 +40,6 @@ Windows is a supported download target. The step-wrapper (`opr8r`), REST API, ka | Feature | Status | Reason | Workaround | |---------|--------|--------|------------| | Relay hub / `opr8r relay` | ❌ Blocked | Requires Unix domain sockets (`tokio::net::unix`), which are not available on Windows | None yet. Planned: named-pipe or TCP-loopback transport in a future release | -| Backstage Server | ❌ Blocked | Not yet ported to Windows | None. No timeline committed | | tmux session manager | ❌ N/A | tmux does not run on Windows | Use the VS Code Extension | | cmux session manager | ❌ N/A | cmux is macOS-specific | Use the VS Code Extension | | Zellij session manager | ❌ N/A | Zellij does not run on Windows | Use the VS Code Extension | diff --git a/docs/getting-started/platforms/coder.md b/docs/getting-started/platforms/coder.md new file mode 100644 index 0000000..e367d7f --- /dev/null +++ b/docs/getting-started/platforms/coder.md @@ -0,0 +1,123 @@ +--- +title: "Coder" +description: "Run Operator as a background service in Coder workspaces via Terraform module." +layout: doc +--- + +# Coder + +Supported + +Run [Operator](https://operator.untra.io) as a background REST API server inside your [Coder](https://coder.com) workspace. The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. + +**Registry:** [`registry.coder.com/untra/operator/coder`](https://registry.coder.com/modules/operator) + +## Usage + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +### Custom configuration + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + port = 7008 + max_parallel_agents = 4 + session_wrapper = "tmux" +} +``` + +### Full TOML override + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + config_toml = <<-EOT + [rest_api] + enabled = true + port = 7008 + + [agents] + max_parallel = 4 + health_check_interval = 30 + + [sessions] + wrapper = "tmux" + + [[delegators]] + name = "default" + tool = "claude-code" + model = "sonnet" + EOT +} +``` + +## Variables + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_id` | `string` | (required) | The ID of a Coder agent | +| `port` | `number` | `7008` | The port for the operator REST API server | +| `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | +| `slug` | `string` | `"operator"` | Application slug | +| `install_version` | `string` | `"{{ site.version }}"` | GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binary into | +| `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | +| `config_toml` | `string` | `""` | Raw TOML config (written verbatim instead of auto-generated config) | +| `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | +| `session_wrapper` | `string` | `"tmux"` | Session wrapper type (`tmux`, `cmux`, or `zellij`) | +| `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | +| `order` | `number` | `null` | Position of the app in the Coder dashboard (lower = first) | +| `group` | `string` | `null` | Group that this app belongs to | +| `offline` | `bool` | `false` | Skip downloading; requires pre-installed binary at `install_prefix` | +| `use_cached` | `bool` | `false` | Use cached binary if present, otherwise download | + +## Prerequisites + +The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +## Coder Workspace Context + +Coder automatically injects environment variables into every workspace that Operator can reference in ticket templates and agent prompts: + +- `CODER_WORKSPACE_NAME` — workspace identifier +- `CODER_WORKSPACE_OWNER` — workspace owner username +- `CODER_AGENT_TOKEN` — agent authentication token + +No Operator configuration is needed to access these — they are ambient in the workspace environment. + +## How It Works + +1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) +2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary) +3. Generates a TOML configuration file (or uses the provided `config_toml`) +4. Starts `operator api` as a background process +5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds + +## Troubleshooting + +### Binary download fails + +1. Check that the `install_version` matches a valid [GitHub release tag](https://github.com/untra/operator/releases) +2. Verify the workspace has internet access (or use `offline = true` with a pre-installed binary) +3. Check logs at the configured `log_path` (default: `/tmp/operator.log`) + +### Healthcheck timeout + +1. Verify the port is not already in use: `ss -tlnp | grep 7008` +2. Check operator logs: `cat /tmp/operator.log` +3. Ensure the session wrapper (tmux by default) is installed in the workspace image + +### Port conflicts + +Change the `port` variable to an unused port. Remember to update any other services or extensions that connect to the Operator API. diff --git a/docs/getting-started/platforms/index.md b/docs/getting-started/platforms/index.md new file mode 100644 index 0000000..8c7e19c --- /dev/null +++ b/docs/getting-started/platforms/index.md @@ -0,0 +1,15 @@ +--- +title: "Supported Workspace Platforms" +description: "Workspace platform integrations for running Operator in remote development environments." +layout: doc +--- + +# Supported Workspace Platforms + +Operator can run as a background service in remote workspace platforms, providing API access and dashboard visibility without requiring a local terminal. + +## Available Options + +| Option | Status | Notes | +|--------|--------|-------| +| [Coder](/getting-started/platforms/coder/) | Supported | Terraform module, runs Operator as background API server with dashboard | diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index 6e6adcf..396e0c0 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -80,7 +80,6 @@ dnf install tmux Windows support has some limitations: - **Session Manager**: VS Code Extension is required (tmux not available) -- **Backstage Server**: Not supported on Windows - **Notifications**: Native notifications are planned; currently logs only See [Platform Support & Limitations](/getting-started/platform-support/) for a complete list of OS-specific gaps, their reasons, and planned resolutions. @@ -95,10 +94,6 @@ At least one AI coding agent should be installed: - [Codex](/getting-started/agents/codex/) - [Gemini](/getting-started/agents/gemini/) -### Backstage Server (macOS/Linux only) - -For centralized project management across multiple repositories, see [Backstage Server Setup](/getting-started/backstage-server/). Note: Not supported on Windows. - ### Kanban Integration For issue tracking integration, configure: diff --git a/docs/getting-started/sessions/cursor.md b/docs/getting-started/sessions/cursor.md new file mode 100644 index 0000000..dd501af --- /dev/null +++ b/docs/getting-started/sessions/cursor.md @@ -0,0 +1,113 @@ +--- +title: "Cursor" +description: "Cursor IDE integration via the operator-terminals VS Code extension and Cursor's native MCP support." +layout: doc +--- + +# Cursor + +Supported + +Install from OpenVSX + +[Cursor](https://www.cursor.com) is a fork of VS Code that natively runs most VS Code extensions and adds its own MCP configuration surface. The same `operator-terminals` extension that powers the VS Code session manager runs in Cursor unmodified — the only difference is **where** the extension writes the MCP server entry. + +## Two Integration Paths + +### 1. Extension Path (recommended) + +Install `operator-terminals` from OpenVSX (Cursor's default extension registry) or via a downloaded `.vsix`, then run `Operator: Connect MCP Server` from the command palette. Inside Cursor, the extension writes the operator MCP entry to `~/.cursor/mcp.json` instead of VS Code's workspace `mcp.servers` — Cursor's MCP UI only surfaces user-scope entries, so writing workspace config would have no effect. + +This path also gives you the sidebar (Queue / In Progress / Completed), styled terminals, and the rest of the extension's features. + +### 2. Native MCP Path (no extension) + +If you don't want the extension, you can register operator directly with Cursor: + +1. From operator's TUI, navigate to the **Connections** panel and trigger the `WriteAndOpenMcpClientConfig` action with `client: "cursor"`. This writes a ready-to-paste snippet to `/operator/mcp/cursor.json` and opens it in your editor. +2. Copy the snippet's `mcpServers.operator` block into your `~/.cursor/mcp.json`, merging with any existing entries. +3. Restart Cursor or toggle the server in **Cursor Settings → MCP**. + +## Installation + +### From OpenVSX (Cursor's default) + +1. Open Cursor. +2. Open the Extensions sidebar (`Cmd+Shift+X` / `Ctrl+Shift+X`). +3. Search for **Operator Terminals**. +4. Click **Install**. + +### Manual Installation + +1. Download the `.vsix` from [GitHub releases](https://github.com/untra/operator/releases){:target="_blank"}. +2. In Cursor, open Extensions, click the `…` menu, and pick **Install from VSIX…**. + +## Configuration + +The extension shares the same configuration as the VS Code session manager. Settings are scoped under `operator.*` in Cursor's `settings.json`. + +| Setting | Default | Description | +|---------|---------|-------------| +| `operator.webhookPort` | `7009` | Port for webhook server | +| `operator.autoStart` | `true` | Start server on Cursor launch | +| `operator.terminalPrefix` | `op-` | Prefix for managed terminal names | +| `operator.ticketsDir` | `.tickets` | Path to tickets directory | +| `operator.apiUrl` | `http://localhost:7008` | Operator REST API URL | + +## MCP Integration + +Cursor's `~/.cursor/mcp.json` uses the `mcpServers` shape with `command`, `args`, and `cwd` — stdio only. SSE-style URL entries are not honored by Cursor's MCP UI. + +### Requirements + +- Operator must be running with `[mcp].stdio_advertised = true` in its config (this is the default). Restart the operator API after toggling. +- The operator binary path written into `~/.cursor/mcp.json` is taken from the running operator process — if you reinstall or move the binary, re-run `Operator: Connect MCP Server` to refresh the path. + +### Merge Semantics + +The extension's Cursor-write path is additive: + +- Any existing top-level keys in `~/.cursor/mcp.json` are preserved. +- Any existing `mcpServers.*` entries (other servers you've registered) are preserved. +- Only `mcpServers.operator` is set or overwritten on each run. + +If `~/.cursor/mcp.json` exists but contains malformed JSON, the extension shows an error and refuses to overwrite the file — fix or remove it manually and re-run the command. + +## Commands + +Same set as the VS Code session manager — access via the command palette (`Cmd+Shift+P`): + +| Command | Description | +|---------|-------------| +| `Operator: Start Webhook Server` | Start the webhook server | +| `Operator: Stop Webhook Server` | Stop the webhook server | +| `Operator: Connect MCP Server` | Register operator with Cursor's MCP (writes `~/.cursor/mcp.json`) | +| `Operator: Launch Ticket` | Launch a ticket in a new terminal | +| `Operator: Show Server Status` | Display server status | + +## Requirements + +- Cursor with stable MCP support (Cursor 0.42 or later; verify `Settings → MCP` is present). +- Operator built from `main` or a release that includes the MCP stdio plan (`operator mcp` subcommand and `[mcp].stdio_advertised` config flag). + +## Troubleshooting + +### `Operator MCP stdio entrypoint is not advertised` + +The descriptor your operator API returned did not include a `stdio` field, so Cursor cannot register the server. Set `[mcp].stdio_advertised = true` in your operator config and restart the API, then re-run `Operator: Connect MCP Server`. + +### Editing `~/.cursor/mcp.json` manually + +Open the file in any text editor. To remove the operator entry, delete the `mcpServers.operator` key (preserve the rest of the file's structure). Save and restart Cursor or toggle the server in **Cursor Settings → MCP**. + +### Switching between VS Code and Cursor on the same workspace + +If you previously ran `Operator: Connect MCP Server` in stock VS Code on the same workspace, the workspace `mcp.servers.operator` entry still exists in that workspace's `.vscode/settings.json`. Running the command again in Cursor adds a user-scope entry in `~/.cursor/mcp.json`. **Both work in their respective hosts**; the extension intentionally does not delete the workspace entry from inside Cursor (doing so would surprise you when you reopen the workspace in VS Code). + +If you want a clean slate in only one of the two editors, delete the entry from the file Cursor or VS Code doesn't see (`.vscode/settings.json` for VS Code, `~/.cursor/mcp.json` for Cursor). + +### Cursor doesn't see the operator server after running the command + +1. Check that `~/.cursor/mcp.json` exists and contains `mcpServers.operator` with `command`, `args`, and `cwd`. +2. Restart Cursor or open **Cursor Settings → MCP** and toggle the operator server off and on. +3. Confirm the `command` path in the JSON is executable (`ls -l ` and run it manually with ` mcp` — it should hang waiting for JSON-RPC on stdin, which is correct). diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md index 204134d..98d544f 100644 --- a/docs/getting-started/sessions/index.md +++ b/docs/getting-started/sessions/index.md @@ -13,9 +13,11 @@ Operator supports multiple session management backends for running AI coding age | Option | Status | Notes | |--------|--------|-------| | [VS Code Extension](/getting-started/sessions/vscode/) | Recommended (Preferred) | Integrated terminals in VS Code, works on all platforms | +| [Cursor](/getting-started/sessions/cursor/) | Supported | Cursor IDE (VS Code fork); same extension, native MCP via `~/.cursor/mcp.json` | | [tmux](/getting-started/sessions/tmux/) | Supported | Terminal multiplexer, ideal for headless/server environments | | [cmux](/getting-started/sessions/cmux/) | Supported | macOS terminal multiplexer, manages workspaces within cmux | | [Zellij](/getting-started/sessions/zellij/) | Supported | Terminal workspace manager, tab-per-agent model (macOS/Linux) | +| [Zed](/getting-started/sessions/zed/) | Supported | Zed editor extension; MCP context server, ACP agent, slash commands | ## How It Works @@ -30,6 +32,8 @@ Session managers provide: **VS Code Extension** is the recommended choice for most users. It provides an integrated experience with ticket management, color-coded terminals, and works seamlessly on macOS, Linux, and Windows without additional setup. +**Cursor** is the right choice if you already use Cursor as your daily editor. The same `operator-terminals` extension installs from OpenVSX, and `Operator: Connect MCP Server` writes to Cursor's native `~/.cursor/mcp.json` (stdio) so the operator tool surface shows up in Cursor's MCP UI and chat. + **tmux** remains an excellent choice for headless/server environments, SSH sessions, and users who prefer terminal-based workflows. It's particularly useful for remote servers where VS Code may not be available. **cmux** is a macOS-native option for users already working within cmux. It launches agents as cmux windows or workspaces. Requires macOS and that Operator is running inside a cmux session. diff --git a/docs/getting-started/sessions/zed.md b/docs/getting-started/sessions/zed.md new file mode 100644 index 0000000..d41556b --- /dev/null +++ b/docs/getting-started/sessions/zed.md @@ -0,0 +1,96 @@ +--- +title: "Zed" +description: "Zed editor integration for Operator via MCP context server, ACP agent, and slash commands." +layout: doc +--- + +# Zed + +Alpha + +
+This integration is in alpha and may have limited functionality or incomplete support. +
+ +The [Zed](https://zed.dev) extension for Operator provides three integration layers: an MCP context server for tools and resources, an ACP agent server for delegated prompts, and slash commands for quick operations. + +## Prerequisites + +- [Operator](https://operator.untra.io) installed and on PATH +- Zed editor + +## Installation + +1. Open Zed +2. Open the Extensions panel (**Zed > Extensions** or `Cmd+Shift+X`) +3. Search for **Operator** +4. Click **Install** + +## Setup + +### MCP Context Server (automatic) + +After installing the extension, Zed automatically registers `operator mcp` as a context server. All Operator tools appear in the Agent Panel: + +- `operator_health` / `operator_status` — system health +- `operator_list_tickets` — query queue, in-progress, completed tickets +- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` — ticket lifecycle +- `operator_create_ticket` — create tickets from templates +- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` — registry queries +- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` — queue operations +- `operator_approve_agent` / `operator_reject_agent` — review actions + +If the `operator` binary is not found, the extension shows installation instructions. + +### ACP Agent Server (one-time setup) + +Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel — you can send prompts that flow through ACP to a Claude Code delegator. + +## Slash Commands + +| Command | Description | +|---------|-------------| +| `/op-status` | Show Operator health and status | +| `/op-queue` | List tickets in queue | +| `/op-launch TICKET-ID` | Launch a ticket | +| `/op-active` | List active agents | +| `/op-completed` | List recently completed tickets | +| `/op-ticket TICKET-ID` | Show ticket details | +| `/op-pause` | Pause queue processing | +| `/op-resume` | Resume queue processing | +| `/op-sync` | Sync kanban collections | +| `/op-approve AGENT-ID` | Approve agent review | +| `/op-reject AGENT-ID REASON` | Reject agent review | +| `/op-setup-agent` | Generate ACP agent server config | + +Commands with arguments support tab-completion from live API data. + +## How It Works + +Operator integrates with Zed through three communication channels: + +- **MCP Context Server** — Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration. +- **ACP Agent Server** — Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed. +- **Slash Commands** — Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant. + +## Configuration + +The Operator binary must be on your PATH. The extension also checks common install locations (`/usr/local/bin`, `/opt/homebrew/bin`). The REST API URL for slash commands defaults to `http://localhost:7008`. + +## Troubleshooting + +### MCP tools not appearing + +1. Verify Operator is on PATH: `which operator` +2. Test MCP server: `operator mcp` (should wait for JSON-RPC input) +3. Check Zed's extension logs: **View > Output > Extensions** + +### Slash commands failing + +1. Check that Operator API is running: `operator api` +2. Verify connectivity: `curl http://localhost:7008/api/v1/health` + +### Extension not appearing + +1. Open the Extensions panel and verify Operator is listed as installed +2. Try **Zed > Extensions > Reload** or restart Zed diff --git a/docs/index.md b/docs/index.md index c510c62..b8de192 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,11 +41,12 @@ These are tools that comparable and aspirational for Operator - [agtx](https://github.com/fynnfluegge/agtx) - [claude-relay](https://github.com/Innestic/claude-relay) -- [gastown](https://github.com/gastownhall/gastown) +- [kanbots](https://github.com/Innestic/claude-relay) ## Similar, but Worse: These are tools that are almost as good, and are inspirational, but just don't quite cut it: - [Ralph Code](https://github.com/frankbria/ralph-claude-code) -- [Vibe Kanban](https://www.vibekanban.com/) \ No newline at end of file +- [Vibe Kanban](https://www.vibekanban.com/) +- [gastown](https://github.com/gastownhall/gastown) \ No newline at end of file diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 2da1698..6a7e353 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -91,31 +91,6 @@ "skill_directory_overrides": {} } }, - "backstage": { - "$ref": "#/$defs/BackstageConfig", - "default": { - "enabled": true, - "display": false, - "port": 7007, - "auto_start": false, - "subpath": "backstage", - "branding_subpath": "branding", - "release_url": "https://github.com/untra/operator/releases/latest/download", - "local_binary_path": null, - "branding": { - "app_title": "Operator Portal", - "org_name": "Operator", - "logo_path": "logo.svg", - "colors": { - "primary": "#cc6c55", - "secondary": "#114145", - "accent": "#f4dbb7", - "warning": "#d46048", - "muted": "#8a4a3a" - } - } - } - }, "rest_api": { "$ref": "#/$defs/RestApiConfig", "default": { @@ -174,6 +149,32 @@ "$ref": "#/$defs/ModelServer" }, "default": [] + }, + "relay": { + "description": "Relay MCP injection configuration", + "$ref": "#/$defs/RelayConfig", + "default": { + "auto_inject_mcp": false + } + }, + "mcp": { + "description": "Model Context Protocol (MCP) server configuration", + "$ref": "#/$defs/McpConfig", + "default": { + "http_enabled": true, + "stdio_advertised": true, + "expose_ticket_write_tools": false, + "external_servers": [] + } + }, + "acp": { + "description": "Agent Client Protocol (ACP) agent configuration", + "$ref": "#/$defs/AcpConfig", + "default": { + "stdio_advertised": true, + "default_delegator": null, + "max_concurrent_sessions": 8 + } } }, "required": [ @@ -199,6 +200,13 @@ "format": "uint", "minimum": 0 }, + "max_agents_per_repo": { + "description": "Maximum concurrent agents per project/repo (default: 1).\nRequires `git.use_worktrees` = true when > 1 to avoid conflicts.", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 1 + }, "health_check_interval": { "type": "integer", "format": "uint64", @@ -1044,140 +1052,6 @@ } } }, - "BackstageConfig": { - "description": "Backstage integration configuration", - "type": "object", - "properties": { - "enabled": { - "description": "Whether Backstage integration is enabled", - "type": "boolean", - "default": true - }, - "display": { - "description": "Whether to show Backstage in the Connections status section", - "type": "boolean", - "default": false - }, - "port": { - "description": "Port for the Backstage server", - "type": "integer", - "format": "uint16", - "minimum": 0, - "maximum": 65535, - "default": 7007 - }, - "auto_start": { - "description": "Auto-start Backstage server when TUI launches", - "type": "boolean", - "default": false - }, - "subpath": { - "description": "Subdirectory within `state_path` for Backstage installation", - "type": "string", - "default": "backstage" - }, - "branding_subpath": { - "description": "Subdirectory within backstage path for branding customization", - "type": "string", - "default": "branding" - }, - "release_url": { - "description": "Base URL for downloading backstage-server binary", - "type": "string", - "default": "https://github.com/untra/operator/releases/latest/download" - }, - "local_binary_path": { - "description": "Optional local path to backstage-server binary\nIf set, this is used instead of downloading from `release_url`", - "type": [ - "string", - "null" - ], - "default": null - }, - "branding": { - "description": "Branding and theming configuration", - "$ref": "#/$defs/BrandingConfig", - "default": { - "app_title": "Operator Portal", - "org_name": "Operator", - "logo_path": "logo.svg", - "colors": { - "primary": "#cc6c55", - "secondary": "#114145", - "accent": "#f4dbb7", - "warning": "#d46048", - "muted": "#8a4a3a" - } - } - } - } - }, - "BrandingConfig": { - "description": "Branding configuration for Backstage portal", - "type": "object", - "properties": { - "app_title": { - "description": "App title shown in header", - "type": "string", - "default": "Operator Portal" - }, - "org_name": { - "description": "Organization name", - "type": "string", - "default": "Operator" - }, - "logo_path": { - "description": "Path to logo SVG (relative to branding path)", - "type": [ - "string", - "null" - ], - "default": null - }, - "colors": { - "description": "Theme colors (uses Operator defaults if not set)", - "$ref": "#/$defs/ThemeColors", - "default": { - "primary": "#cc6c55", - "secondary": "#114145", - "accent": "#f4dbb7", - "warning": "#d46048", - "muted": "#8a4a3a" - } - } - } - }, - "ThemeColors": { - "description": "Theme color configuration for Backstage\nDefault colors match Operator's tmux theme", - "type": "object", - "properties": { - "primary": { - "description": "Primary/accent color (default: salmon #cc6c55)", - "type": "string", - "default": "#cc6c55" - }, - "secondary": { - "description": "Secondary color (default: dark teal #114145)", - "type": "string", - "default": "#114145" - }, - "accent": { - "description": "Accent/highlight color (default: cream #f4dbb7)", - "type": "string", - "default": "#f4dbb7" - }, - "warning": { - "description": "Warning/error color (default: coral #d46048)", - "type": "string", - "default": "#d46048" - }, - "muted": { - "description": "Muted text color (default: darker salmon #8a4a3a)", - "type": "string", - "default": "#8a4a3a" - } - } - }, "RestApiConfig": { "description": "REST API server configuration", "type": "object", @@ -1405,6 +1279,11 @@ "type": "string" }, "default": {} + }, + "bidirectional": { + "description": "When true, operator pushes status changes and activity logs back to this kanban project.\nTicket state changes (todo→doing, doing→done) and step completions with delegator info\nare reflected upstream. Default: false.", + "type": "boolean", + "default": false } } }, @@ -1605,6 +1484,14 @@ "null" ], "default": null + }, + "operator_relay": { + "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)", + "type": [ + "boolean", + "null" + ], + "default": null } } }, @@ -1657,6 +1544,121 @@ "name", "kind" ] + }, + "RelayConfig": { + "description": "Relay MCP injection configuration", + "type": "object", + "properties": { + "auto_inject_mcp": { + "description": "When true, automatically inject the relay MCP server for all delegators.\nWhen false (default), relay injection is opt-in per delegator.", + "type": "boolean", + "default": false + } + } + }, + "McpConfig": { + "description": "Model Context Protocol (MCP) server configuration", + "type": "object", + "properties": { + "http_enabled": { + "description": "Whether to mount MCP HTTP/SSE endpoints on the REST API server.\nToggling requires an API restart (no hot-swap of the axum router).", + "type": "boolean", + "default": true + }, + "stdio_advertised": { + "description": "Whether the descriptor endpoint advertises the `operator mcp` stdio\ncommand. Set to false on multi-tenant/remote deployments where clients\nshouldn't spawn local subprocesses.", + "type": "boolean", + "default": true + }, + "expose_ticket_write_tools": { + "description": "Whether to expose ticket-mutating tools (claim, complete, return-to-queue,\ncreate) over MCP. Defaults to `false` because any MCP client can call them.", + "type": "boolean", + "default": false + }, + "external_servers": { + "description": "External MCP servers to inject into spawned agent sessions.\nEach entry produces a separate `--mcp-config` file alongside the\nrelay config when launching Claude Code agents.", + "type": "array", + "items": { + "$ref": "#/$defs/ExternalMcpServer" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "ExternalMcpServer": { + "description": "An external MCP server to inject into spawned agent sessions.\n\nValues in `command`, `args`, and `env` support `${VAR}` interpolation,\nexpanded at spawn time from the operator process environment.\n\nWhen `discover_from` is set, operator reads an MCP server spec from that\nJSON sidecar file at spawn time. The sidecar must contain a top-level\n`mcpServer` object with `command`, `args`, and `env` fields. If the file\nis absent and `command` is empty, the server is silently skipped.", + "type": "object", + "properties": { + "name": { + "description": "Server name used as the key in the `mcpServers` JSON object\n(e.g., \"kanbots\"). Must be unique across all external servers.", + "type": "string" + }, + "command": { + "description": "Command to execute. Supports `${VAR}` interpolation.", + "type": "string", + "default": "" + }, + "args": { + "description": "Command arguments. Each element supports `${VAR}` interpolation.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "env": { + "description": "Environment variables passed to the MCP server process.\nValues support `${VAR}` interpolation.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "enabled": { + "description": "Whether this server is enabled. Allows disabling without removing config.", + "type": "boolean", + "default": true + }, + "discover_from": { + "description": "Path to a JSON sidecar discovery file. Relative paths resolve from\nthe project directory. The sidecar must contain `{ \"mcpServer\": { ... } }`.\nWhen the file exists, its `mcpServer` spec is used verbatim (overriding\n`command`/`args`/`env`). When absent and `command` is empty, the server\nis silently skipped.", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "name" + ] + }, + "AcpConfig": { + "description": "Agent Client Protocol (ACP) agent configuration.\n\nOperator runs as an ACP agent over stdio when editors (Zed, `JetBrains`,\nEmacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session\nmaps to an in-progress ACP ticket and a delegator subprocess.", + "type": "object", + "properties": { + "stdio_advertised": { + "description": "Whether the dashboard advertises the `operator acp` stdio entrypoint\n(and editor-config snippet actions). Set to false on machines that\nshouldn't be used as ACP agents.", + "type": "boolean", + "default": true + }, + "default_delegator": { + "description": "Name of the delegator (from `[[delegators]]`) to use for ACP prompts.\nIf unset or not found, falls back to the operator's default delegator\nresolution.", + "type": [ + "string", + "null" + ], + "default": null + }, + "max_concurrent_sessions": { + "description": "Maximum number of concurrent ACP sessions. New `session/new` requests\nbeyond this limit are rejected with a JSON-RPC error.", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 8 + } + }, + "additionalProperties": false } } } \ No newline at end of file diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 7ff5b7f..c7d97c1 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -42,13 +42,15 @@ JSON Schema for the Operator configuration file (`config.toml`). | `tmux` | → `TmuxConfig` | No | | | `sessions` | → `SessionsConfig` | No | Session wrapper configuration (tmux, vscode, or cmux) | | `llm_tools` | → `LlmToolsConfig` | No | | -| `backstage` | → `BackstageConfig` | No | | | `rest_api` | → `RestApiConfig` | No | | | `git` | → `GitConfig` | No | | | `kanban` | → `KanbanConfig` | No | Kanban provider configuration for syncing issues from Jira, Linear, etc. | | `version_check` | → `VersionCheckConfig` | No | Version check configuration for automatic update notifications | | `delegators` | `array` | No | Agent delegator configurations for autonomous ticket launching | | `model_servers` | `array` | No | User-declared model servers (ollama, lmstudio, any OpenAI-compat host). Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. | +| `relay` | → `RelayConfig` | No | Relay MCP injection configuration | +| `mcp` | → `McpConfig` | No | Model Context Protocol (MCP) server configuration | +| `acp` | → `AcpConfig` | No | Agent Client Protocol (ACP) agent configuration | ## Type Definitions @@ -58,6 +60,7 @@ JSON Schema for the Operator configuration file (`config.toml`). | --- | --- | --- | --- | | `max_parallel` | `integer` | Yes | | | `cores_reserved` | `integer` | Yes | | +| `max_agents_per_repo` | `integer` | No | Maximum concurrent agents per project/repo (default: 1). Requires `git.use_worktrees` = true when > 1 to avoid conflicts. | | `health_check_interval` | `integer` | Yes | | | `generation_timeout_secs` | `integer` | No | Timeout in seconds for each agent generation (default: 300 = 5 min) | | `sync_interval` | `integer` | No | Interval in seconds between ticket-session syncs (default: 60) | @@ -349,46 +352,6 @@ Per-tool skill directory overrides | `global` | `array` | No | Additional global skill directories | | `project` | `array` | No | Additional project-relative skill directories | -### BackstageConfig - -Backstage integration configuration - -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `enabled` | `boolean` | No | Whether Backstage integration is enabled | -| `display` | `boolean` | No | Whether to show Backstage in the Connections status section | -| `port` | `integer` | No | Port for the Backstage server | -| `auto_start` | `boolean` | No | Auto-start Backstage server when TUI launches | -| `subpath` | `string` | No | Subdirectory within `state_path` for Backstage installation | -| `branding_subpath` | `string` | No | Subdirectory within backstage path for branding customization | -| `release_url` | `string` | No | Base URL for downloading backstage-server binary | -| `local_binary_path` | `string` \| `null` | No | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` | -| `branding` | → `BrandingConfig` | No | Branding and theming configuration | - -### BrandingConfig - -Branding configuration for Backstage portal - -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `app_title` | `string` | No | App title shown in header | -| `org_name` | `string` | No | Organization name | -| `logo_path` | `string` \| `null` | No | Path to logo SVG (relative to branding path) | -| `colors` | → `ThemeColors` | No | Theme colors (uses Operator defaults if not set) | - -### ThemeColors - -Theme color configuration for Backstage -Default colors match Operator's tmux theme - -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `primary` | `string` | No | Primary/accent color (default: salmon #cc6c55) | -| `secondary` | `string` | No | Secondary color (default: dark teal #114145) | -| `accent` | `string` | No | Accent/highlight color (default: cream #f4dbb7) | -| `warning` | `string` | No | Warning/error color (default: coral #d46048) | -| `muted` | `string` | No | Muted text color (default: darker salmon #8a4a3a) | - ### RestApiConfig REST API server configuration @@ -479,6 +442,7 @@ Per-project/team sync configuration for a kanban provider | `sync_statuses` | `array` | No | Workflow statuses to sync (empty = default/first status only) | | `collection_name` | `string` \| `null` | No | Optional `IssueTypeCollection` name this project maps to. Not required for kanban onboarding or sync. | | `type_mappings` | `object` | No | Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). Multiple kanban types can map to the same operator template. | +| `bidirectional` | `boolean` | No | When true, operator pushes status changes and activity logs back to this kanban project. Ticket state changes (todo→doing, doing→done) and step completions with delegator info are reflected upstream. Default: false. | ### LinearConfig @@ -557,6 +521,7 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override. | `docker` | `boolean` \| `null` | No | Run in docker container (None = use global `launch.docker.enabled`) | | `prompt_prefix` | `string` \| `null` | No | Prompt text to prepend before the generated step prompt | | `prompt_suffix` | `string` \| `null` | No | Prompt text to append after the generated step prompt | +| `operator_relay` | `boolean` \| `null` | No | Override global relay auto-inject MCP setting per-delegator (None = use global setting) | ### ModelServer @@ -579,3 +544,57 @@ in config. | `extra_env` | `object` | No | Additional environment variables set when spawning agents that use this server | | `display_name` | `string` \| `null` | No | Optional display name for UI | +### RelayConfig + +Relay MCP injection configuration + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `auto_inject_mcp` | `boolean` | No | When true, automatically inject the relay MCP server for all delegators. When false (default), relay injection is opt-in per delegator. | + +### McpConfig + +Model Context Protocol (MCP) server configuration + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `http_enabled` | `boolean` | No | Whether to mount MCP HTTP/SSE endpoints on the REST API server. Toggling requires an API restart (no hot-swap of the axum router). | +| `stdio_advertised` | `boolean` | No | Whether the descriptor endpoint advertises the `operator mcp` stdio command. Set to false on multi-tenant/remote deployments where clients shouldn't spawn local subprocesses. | +| `expose_ticket_write_tools` | `boolean` | No | Whether to expose ticket-mutating tools (claim, complete, return-to-queue, create) over MCP. Defaults to `false` because any MCP client can call them. | +| `external_servers` | `array` | No | External MCP servers to inject into spawned agent sessions. Each entry produces a separate `--mcp-config` file alongside the relay config when launching Claude Code agents. | + +### ExternalMcpServer + +An external MCP server to inject into spawned agent sessions. + +Values in `command`, `args`, and `env` support `${VAR}` interpolation, +expanded at spawn time from the operator process environment. + +When `discover_from` is set, operator reads an MCP server spec from that +JSON sidecar file at spawn time. The sidecar must contain a top-level +`mcpServer` object with `command`, `args`, and `env` fields. If the file +is absent and `command` is empty, the server is silently skipped. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | Server name used as the key in the `mcpServers` JSON object (e.g., "kanbots"). Must be unique across all external servers. | +| `command` | `string` | No | Command to execute. Supports `${VAR}` interpolation. | +| `args` | `array` | No | Command arguments. Each element supports `${VAR}` interpolation. | +| `env` | `object` | No | Environment variables passed to the MCP server process. Values support `${VAR}` interpolation. | +| `enabled` | `boolean` | No | Whether this server is enabled. Allows disabling without removing config. | +| `discover_from` | `string` \| `null` | No | Path to a JSON sidecar discovery file. Relative paths resolve from the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. When the file exists, its `mcpServer` spec is used verbatim (overriding `command`/`args`/`env`). When absent and `command` is empty, the server is silently skipped. | + +### AcpConfig + +Agent Client Protocol (ACP) agent configuration. + +Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`, +Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session +maps to an in-progress ACP ticket and a delegator subprocess. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `stdio_advertised` | `boolean` | No | Whether the dashboard advertises the `operator acp` stdio entrypoint (and editor-config snippet actions). Set to false on machines that shouldn't be used as ACP agents. | +| `default_delegator` | `string` \| `null` | No | Name of the delegator (from `[[delegators]]`) to use for ACP prompts. If unset or not found, falls back to the operator's default delegator resolution. | +| `max_concurrent_sessions` | `integer` | No | Maximum number of concurrent ACP sessions. New `session/new` requests beyond this limit are rejected with a JSON-RPC error. | + diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 1f3f22e..23d7a4a 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -10,16 +10,156 @@ "license": { "name": "MIT" }, - "version": "0.1.30" + "version": "0.2.0" }, "paths": { + "/api/v1/agents/active": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get all active agents", + "description": "Returns a list of all currently running agents with their status and details.", + "operationId": "agents_active", + "responses": { + "200": { + "description": "Active agents list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActiveAgentsResponse" + } + } + } + } + } + } + }, + "/api/v1/agents/{agent_id}": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get details for a single agent by ID", + "description": "Returns full details for a specific agent, including all tracked state.", + "operationId": "agents_get_detail", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to look up", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Agent details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDetailResponse" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/v1/agents/{agent_id}/approve": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Approve an agent's pending review", + "description": "Clears the review state and signals the agent to continue.\nThe agent must be in `awaiting_input` status with a pending review.", + "operationId": "agents_approve_review", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to approve", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Review approved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/v1/agents/{agent_id}/reject": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Reject an agent's pending review", + "description": "Signals the agent that the review was rejected with feedback.\nThe agent should re-do the work based on the rejection reason.", + "operationId": "agents_reject_review", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to reject", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectReviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Review rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, "/api/v1/collections": { "get": { "tags": [ "Collections" ], "summary": "List all collections", - "operationId": "list", + "operationId": "collections_list", "responses": { "200": { "description": "List of all collections", @@ -43,7 +183,7 @@ "Collections" ], "summary": "Get the currently active collection", - "operationId": "get_active", + "operationId": "collections_get_active", "responses": { "200": { "description": "Active collection", @@ -74,7 +214,7 @@ "Collections" ], "summary": "Get a single collection by name", - "operationId": "get_one", + "operationId": "collections_get_one", "parameters": [ { "name": "name", @@ -116,7 +256,7 @@ "Collections" ], "summary": "Activate a collection", - "operationId": "activate", + "operationId": "collections_activate", "parameters": [ { "name": "name", @@ -152,13 +292,61 @@ } } }, + "/api/v1/configuration": { + "get": { + "tags": [ + "Configuration" + ], + "summary": "Get the current configuration", + "description": "Returns the full operator configuration as a JSON object. The body is left\nopaque in the OpenAPI spec because the `Config` tree is large and no client\nconsumes its OpenAPI schema (the TS `Config` type is generated separately by\nts-rs).", + "operationId": "configuration_get", + "responses": { + "200": { + "description": "Current configuration as a JSON object", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Configuration" + ], + "summary": "Update configuration and save to disk", + "operationId": "configuration_update", + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated configuration as a JSON object", + "content": { + "application/json": { + "schema": {} + } + } + }, + "500": { + "description": "Failed to save configuration" + } + } + } + }, "/api/v1/delegators": { "get": { "tags": [ "Delegators" ], "summary": "List all configured delegators", - "operationId": "list", + "operationId": "delegators_list", "responses": { "200": { "description": "List of delegators", @@ -177,7 +365,7 @@ "Delegators" ], "summary": "Create a new delegator", - "operationId": "create", + "operationId": "delegators_create", "requestBody": { "content": { "application/json": { @@ -212,7 +400,7 @@ ], "summary": "Create a delegator from a detected LLM tool", "description": "Pre-populates delegator fields from the detected tool, requiring minimal input.", - "operationId": "create_from_tool", + "operationId": "delegators_create_from_tool", "requestBody": { "content": { "application/json": { @@ -249,7 +437,7 @@ "Delegators" ], "summary": "Get a single delegator by name", - "operationId": "get_one", + "operationId": "delegators_get_one", "parameters": [ { "name": "name", @@ -282,7 +470,7 @@ "Delegators" ], "summary": "Update an existing delegator", - "operationId": "update", + "operationId": "delegators_update", "parameters": [ { "name": "name", @@ -325,7 +513,7 @@ "Delegators" ], "summary": "Delete a delegator by name", - "operationId": "delete", + "operationId": "delegators_delete", "parameters": [ { "name": "name", @@ -360,7 +548,7 @@ "Health" ], "summary": "Health check endpoint", - "operationId": "health", + "operationId": "health_check", "responses": { "200": { "description": "Service is healthy", @@ -381,7 +569,7 @@ "Issue Types" ], "summary": "List all issue types", - "operationId": "list", + "operationId": "issuetypes_list", "responses": { "200": { "description": "List of all issue types", @@ -403,7 +591,7 @@ "Issue Types" ], "summary": "Create a new issue type", - "operationId": "create", + "operationId": "issuetypes_create", "requestBody": { "content": { "application/json": { @@ -454,7 +642,7 @@ "Issue Types" ], "summary": "Get a single issue type by key", - "operationId": "get_one", + "operationId": "issuetypes_get_one", "parameters": [ { "name": "key", @@ -494,7 +682,7 @@ "Issue Types" ], "summary": "Update an existing issue type", - "operationId": "update", + "operationId": "issuetypes_update", "parameters": [ { "name": "key", @@ -564,7 +752,7 @@ "Issue Types" ], "summary": "Delete an issue type", - "operationId": "delete", + "operationId": "issuetypes_delete", "parameters": [ { "name": "key", @@ -609,7 +797,7 @@ "Steps" ], "summary": "List all steps for an issue type", - "operationId": "list", + "operationId": "steps_list", "parameters": [ { "name": "key", @@ -654,7 +842,7 @@ "Steps" ], "summary": "Get a single step by name", - "operationId": "get_one", + "operationId": "steps_get_one", "parameters": [ { "name": "key", @@ -703,7 +891,7 @@ "Steps" ], "summary": "Update a step", - "operationId": "update", + "operationId": "steps_update", "parameters": [ { "name": "key", @@ -778,58 +966,51 @@ } } }, - "/api/v1/llm-tools": { - "get": { + "/api/v1/kanban/config": { + "put": { "tags": [ - "LLM Tools" + "Kanban" ], - "summary": "List detected LLM tools with model aliases", - "operationId": "list", - "responses": { - "200": { - "description": "List of detected LLM tools", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LlmToolsResponse" - } + "summary": "PUT /`api/v1/kanban/config`", + "description": "Write or upsert a kanban provider+project section into `config.toml`.\nDoes NOT receive the actual secret — only the env var name (`api_key_env`).", + "operationId": "kanban_write_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteKanbanConfigRequest" } } - } - } - } - }, - "/api/v1/llm-tools/default": { - "get": { - "tags": [ - "LLM Tools" - ], - "summary": "Get the current default LLM tool and model", - "operationId": "get_default", + }, + "required": true + }, "responses": { "200": { - "description": "Current default LLM", + "description": "Config section written/upserted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DefaultLlmResponse" + "$ref": "#/components/schemas/WriteKanbanConfigResponse" } } } } } - }, - "put": { + } + }, + "/api/v1/kanban/projects": { + "post": { "tags": [ - "LLM Tools" + "Kanban" ], - "summary": "Set the global default LLM tool and model", - "operationId": "set_default", + "summary": "POST /`api/v1/kanban/projects`", + "description": "List available projects/teams for the given provider using ephemeral\ncredentials. No persistence side effects.", + "operationId": "kanban_list_projects", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetDefaultLlmRequest" + "$ref": "#/components/schemas/ListKanbanProjectsRequest" } } }, @@ -837,74 +1018,63 @@ }, "responses": { "200": { - "description": "Default LLM set", + "description": "Available projects/teams for the provider", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DefaultLlmResponse" + "$ref": "#/components/schemas/ListKanbanProjectsResponse" } } } - }, - "404": { - "description": "Tool not detected" } } } }, - "/api/v1/mcp/descriptor": { - "get": { + "/api/v1/kanban/session-env": { + "post": { "tags": [ - "MCP" + "Kanban" ], - "summary": "MCP descriptor endpoint", - "description": "Returns metadata for building a VS Code MCP deep link.\nThe transport URL is derived from the request Host header\nso it reflects the actual running port.", - "operationId": "descriptor", - "responses": { - "200": { - "description": "MCP server descriptor", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpDescriptorResponse" - } + "summary": "POST /`api/v1/kanban/session-env`", + "description": "Set kanban env vars on the server process for the current session so\nsubsequent `from_config()` calls find the API key. Returns a\n`shell_export_block` with placeholder values for the client to display.", + "operationId": "kanban_set_session_env", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetKanbanSessionEnvRequest" } } - } - } - } - }, - "/api/v1/model-servers": { - "get": { - "tags": [ - "ModelServers" - ], - "summary": "List all model servers (user-declared + implicit builtins)", - "operationId": "list", + }, + "required": true + }, "responses": { "200": { - "description": "List of model servers", + "description": "Session env vars set; returns a shell export block", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServersResponse" + "$ref": "#/components/schemas/SetKanbanSessionEnvResponse" } } } } } - }, + } + }, + "/api/v1/kanban/validate": { "post": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "Create a new model server", - "operationId": "create", + "summary": "POST /`api/v1/kanban/validate`", + "description": "Validate credentials against the live provider API without persisting\nanything. Auth failures return `valid: false` with an `error` string\nrather than a 4xx/5xx status so clients can display errors inline.", + "operationId": "kanban_validate_credentials", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateModelServerRequest" + "$ref": "#/components/schemas/ValidateKanbanCredentialsRequest" } } }, @@ -912,33 +1082,40 @@ }, "responses": { "200": { - "description": "Model server created", + "description": "Validation result (valid flag + optional error)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "$ref": "#/components/schemas/ValidateKanbanCredentialsResponse" } } } - }, - "409": { - "description": "Model server already exists" } } } }, - "/api/v1/model-servers/{name}": { + "/api/v1/kanban/{provider}/{project_key}/issuetypes": { "get": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "Get a single model server by name", - "operationId": "get_one", + "summary": "GET /`api/v1/kanban/:provider/:project_key/issuetypes`", + "description": "Returns kanban issue types from the persisted catalog for a given provider/project.\nFalls back to fetching live from the provider if no catalog exists.", + "operationId": "kanban_external_issue_types", "parameters": [ { - "name": "name", + "name": "provider", "in": "path", - "description": "Model server name", + "description": "Kanban provider name (e.g. jira, linear, github)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_key", + "in": "path", + "description": "Provider project/team key", "required": true, "schema": { "type": "string" @@ -947,72 +1124,89 @@ ], "responses": { "200": { - "description": "Model server details", + "description": "External issue types", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIssueTypeSummary" + } } } } }, - "404": { - "description": "Model server not found" + "400": { + "description": "Unknown provider/project" + }, + "500": { + "description": "Failed to read catalog or fetch from provider" } } - }, - "delete": { + } + }, + "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync": { + "post": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "Delete a user-declared model server by name", - "description": "Implicit builtin servers cannot be deleted.", - "operationId": "delete", + "summary": "POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync`", + "description": "Refreshes the local kanban issue type catalog from the provider.", + "operationId": "kanban_sync_issue_types", "parameters": [ { - "name": "name", + "name": "provider", "in": "path", - "description": "Model server name", + "description": "Kanban provider name (e.g. jira, linear, github)", "required": true, "schema": { "type": "string" } - } - ], + }, + { + "name": "project_key", + "in": "path", + "description": "Provider project/team key", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "Model server deleted", + "description": "Synced issue types", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "$ref": "#/components/schemas/SyncKanbanIssueTypesResponse" } } } }, - "404": { - "description": "Model server not found" + "400": { + "description": "Unknown provider/project" }, - "409": { - "description": "Cannot delete implicit builtin server" + "500": { + "description": "Failed to sync from provider" } } } }, - "/api/v1/skills": { + "/api/v1/llm-tools": { "get": { "tags": [ - "Skills" + "LLM Tools" ], - "summary": "List all discovered skills across LLM tools", - "operationId": "list", + "summary": "List detected LLM tools with model aliases", + "operationId": "llm_tools_list", "responses": { "200": { - "description": "List of discovered skill files", + "description": "List of detected LLM tools", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillsResponse" + "$ref": "#/components/schemas/LlmToolsResponse" } } } @@ -1020,51 +1214,37 @@ } } }, - "/api/v1/status": { + "/api/v1/llm-tools/default": { "get": { "tags": [ - "Health" + "LLM Tools" ], - "summary": "Get service status with registry info", - "operationId": "status", + "summary": "Get the current default LLM tool and model", + "operationId": "llm_tools_get_default", "responses": { "200": { - "description": "Service status with registry info", + "description": "Current default LLM", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StatusResponse" + "$ref": "#/components/schemas/DefaultLlmResponse" } } } } } - } - }, - "/api/v1/tickets/{id}/launch": { - "post": { + }, + "put": { "tags": [ - "Launch" - ], - "summary": "Launch a ticket from the queue", - "description": "Claims the ticket, sets up worktree if needed, generates the LLM command,\nand returns all details needed to execute in an external terminal (VS Code, etc.).", - "operationId": "launch_ticket", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Ticket ID to launch", - "required": true, - "schema": { - "type": "string" - } - } + "LLM Tools" ], + "summary": "Set the global default LLM tool and model", + "operationId": "llm_tools_set_default", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketRequest" + "$ref": "#/components/schemas/SetDefaultLlmRequest" } } }, @@ -1072,519 +1252,2358 @@ }, "responses": { "200": { - "description": "Ticket launched successfully", + "description": "Default LLM set", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketResponse" + "$ref": "#/components/schemas/DefaultLlmResponse" } } } }, - "400": { - "description": "Invalid request" - }, "404": { - "description": "Ticket not found" - }, - "409": { - "description": "Ticket already in progress" + "description": "Tool not detected" } } } - } - }, - "components": { - "schemas": { - "CollectionResponse": { - "type": "object", - "description": "Response for a collection", - "required": [ - "name", - "description", - "types", - "is_active" + }, + "/api/v1/mcp/descriptor": { + "get": { + "tags": [ + "MCP" ], - "properties": { - "description": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "types": { - "type": "array", - "items": { - "type": "string" + "summary": "MCP descriptor endpoint", + "description": "Returns metadata for registering operator with an MCP-capable client.\nThe transport URL is derived from the request Host header so it reflects\nthe actual running port; the stdio entrypoint reflects this binary's path.", + "operationId": "mcp_descriptor", + "responses": { + "200": { + "description": "MCP server descriptor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpDescriptorResponse" + } + } } } } - }, - "CreateDelegatorFromToolRequest": { - "type": "object", - "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.", - "required": [ - "tool_name" + } + }, + "/api/v1/model-servers": { + "get": { + "tags": [ + "ModelServers" ], - "properties": { - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name for UI" - }, - "launch_config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DelegatorLaunchConfigDto", - "description": "Optional launch configuration" + "summary": "List all model servers (user-declared + implicit builtins)", + "operationId": "model_servers_list", + "responses": { + "200": { + "description": "List of model servers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServersResponse" + } } - ] - }, - "model": { - "type": [ - "string", - "null" - ], - "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias." - }, - "model_server": { - "type": [ - "string", - "null" - ], - "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." - }, - "name": { - "type": [ - "string", - "null" - ], - "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`." - }, - "tool_name": { - "type": "string", - "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")" + } } } }, - "CreateDelegatorRequest": { - "type": "object", - "description": "Request to create a new delegator", - "required": [ - "name", - "llm_tool", - "model" + "post": { + "tags": [ + "ModelServers" ], - "properties": { - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name" - }, - "launch_config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DelegatorLaunchConfigDto", - "description": "Optional launch configuration" + "summary": "Create a new model server", + "operationId": "model_servers_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateModelServerRequest" } - ] - }, - "llm_tool": { - "type": "string", - "description": "LLM tool name (must match a detected tool)" - }, - "model": { - "type": "string", - "description": "Model alias" - }, - "model_properties": { - "type": "object", - "description": "Arbitrary model properties", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" } }, - "model_server": { - "type": [ - "string", - "null" - ], - "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + "required": true + }, + "responses": { + "200": { + "description": "Model server created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } }, - "name": { - "type": "string", - "description": "Unique name for the delegator" + "409": { + "description": "Model server already exists" } } - }, - "CreateFieldRequest": { - "type": "object", - "description": "Request to create a field", - "required": [ - "name", + } + }, + "/api/v1/model-servers/{name}": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "Get a single model server by name", + "operationId": "model_servers_get_one", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model server details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + } + } + }, + "delete": { + "tags": [ + "ModelServers" + ], + "summary": "Delete a user-declared model server by name", + "description": "Implicit builtin servers cannot be deleted.", + "operationId": "model_servers_delete", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model server deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + }, + "409": { + "description": "Cannot delete implicit builtin server" + } + } + } + }, + "/api/v1/projects": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List all configured projects with analysis data", + "operationId": "projects_list", + "responses": { + "200": { + "description": "List of projects with analysis data", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectSummary" + } + } + } + } + } + } + } + }, + "/api/v1/projects/{name}/assess": { + "post": { + "tags": [ + "Projects" + ], + "summary": "Create an ASSESS ticket for a project", + "operationId": "projects_assess", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Project name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ASSESS ticket created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssessTicketResponse" + } + } + } + }, + "404": { + "description": "Project not found" + } + } + } + }, + "/api/v1/queue/kanban": { + "get": { + "tags": [ + "Queue" + ], + "summary": "Get kanban board data with tickets grouped by status column", + "description": "Returns tickets organized into four columns: queue, running, awaiting, done.\nTickets are sorted by priority within each column, then by timestamp (FIFO).", + "operationId": "queue_kanban", + "responses": { + "200": { + "description": "Kanban board data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanBoardResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/pause": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Pause queue processing", + "description": "Sets the queue paused state to true, stopping automatic ticket launches.", + "operationId": "queue_pause", + "responses": { + "200": { + "description": "Queue paused successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueControlResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/resume": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Resume queue processing", + "description": "Sets the queue paused state to false, resuming automatic ticket launches.", + "operationId": "queue_resume", + "responses": { + "200": { + "description": "Queue resumed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueControlResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/status": { + "get": { + "tags": [ + "Queue" + ], + "summary": "Get queue status with ticket counts", + "description": "Returns counts of tickets in each state plus breakdown by type.", + "operationId": "queue_status", + "responses": { + "200": { + "description": "Queue status with counts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueStatusResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/sync": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Sync kanban collections", + "description": "Fetches issues from configured external kanban providers (Jira, Linear, etc.)\nand creates local tickets in the queue.", + "operationId": "queue_sync", + "responses": { + "200": { + "description": "Kanban sync completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanSyncResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/sync/{provider}/{project_key}": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Sync a specific kanban collection", + "description": "Fetches issues from a single provider/project combination and creates\nlocal tickets in the queue.", + "operationId": "queue_sync_collection", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "Provider name (jira or linear)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_key", + "in": "path", + "description": "Project/team key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Collection sync completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanSyncResponse" + } + } + } + } + } + } + }, + "/api/v1/sections": { + "get": { + "tags": [ + "Status" + ], + "summary": "List the canonical status sections with health + child rows.", + "description": "Returns all sections (with a `met` flag) rather than hiding unmet ones, so\nthe web UI can render every section and style locked ones. The section logic\nis injected by the binary; without a provider (lib-only/test) this is empty.", + "operationId": "sections_list", + "responses": { + "200": { + "description": "Canonical status sections", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SectionDto" + } + } + } + } + } + } + } + }, + "/api/v1/skills": { + "get": { + "tags": [ + "Skills" + ], + "summary": "List all discovered skills across LLM tools", + "operationId": "skills_list", + "responses": { + "200": { + "description": "List of discovered skill files", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillsResponse" + } + } + } + } + } + } + }, + "/api/v1/status": { + "get": { + "tags": [ + "Health" + ], + "summary": "Get service status with registry info", + "operationId": "health_status", + "responses": { + "200": { + "description": "Service status with registry info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + } + } + } + }, + "/api/v1/tickets/{id}": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "Get full ticket details by ID", + "description": "Returns complete ticket data including content, metadata, step history,\nand session information. Searches queue, in-progress, and completed directories.", + "operationId": "tickets_get_one", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID (e.g., FEAT-7598)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ticket details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetailResponse" + } + } + } + }, + "404": { + "description": "Ticket not found" + } + } + } + }, + "/api/v1/tickets/{id}/launch": { + "post": { + "tags": [ + "Launch" + ], + "summary": "Launch a ticket from the queue", + "description": "Claims the ticket, sets up worktree if needed, generates the LLM command,\nand returns all details needed to execute in an external terminal (VS Code, etc.).", + "operationId": "launch_launch_ticket", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID to launch", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket launched successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchTicketResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "404": { + "description": "Ticket not found" + }, + "409": { + "description": "Ticket already in progress" + } + } + } + }, + "/api/v1/tickets/{id}/status": { + "put": { + "tags": [ + "Tickets" + ], + "summary": "Update a ticket's status", + "description": "Moves a ticket between queue directories based on the target status.\nValid transitions: queued, running, awaiting, done.", + "operationId": "tickets_update_status", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID to update", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTicketStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket status updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTicketStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid status value" + }, + "404": { + "description": "Ticket not found" + } + } + } + }, + "/api/v1/tickets/{id}/steps/{step}/complete": { + "post": { + "tags": [ + "Launch" + ], + "summary": "Report step completion from opr8r wrapper", + "description": "Called by the opr8r wrapper when an LLM command completes.\nReturns next step info and whether to auto-proceed.", + "operationId": "launch_complete_step", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "step", + "in": "path", + "description": "Step name that completed", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StepCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Step completion recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StepCompleteResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "404": { + "description": "Ticket not found" + } + } + } + }, + "/api/v1/tickets/{id}/workflow-export": { + "post": { + "tags": [ + "Workflow" + ], + "summary": "Export a ticket to a Claude dynamic workflow.", + "description": "Resolves the ticket (searching queue, in-progress, and completed), looks up\nits issue type in the registry, and returns the rendered `.js` plus a\nsuggested filename.", + "operationId": "workflow_export", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID (e.g., FEAT-7598)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Generated workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExportResponse" + } + } + } + }, + "404": { + "description": "Ticket or issue type not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ActiveAgentResponse": { + "type": "object", + "description": "A single active agent", + "required": [ + "id", + "ticket_id", + "ticket_type", + "project", + "status", + "mode", + "started_at" + ], + "properties": { + "current_step": { + "type": [ + "string", + "null" + ], + "description": "Current workflow step" + }, + "id": { + "type": "string", + "description": "Agent ID (e.g., \"op-gamesvc-001\")" + }, + "mode": { + "type": "string", + "description": "Execution mode: autonomous, paired" + }, + "project": { + "type": "string", + "description": "Project being worked on" + }, + "session_context_ref": { + "type": [ + "string", + "null" + ], + "description": "Session context reference (e.g. cmux workspace, zellij session)" + }, + "session_pane_ref": { + "type": [ + "string", + "null" + ], + "description": "Session pane reference (e.g. cmux surface, zellij pane)" + }, + "session_window_ref": { + "type": [ + "string", + "null" + ], + "description": "Session window reference ID (e.g. cmux window, tmux session)" + }, + "session_wrapper": { + "type": [ + "string", + "null" + ], + "description": "Which session wrapper is in use: \"tmux\", \"vscode\", \"cmux\", or \"zellij\"" + }, + "started_at": { + "type": "string", + "description": "When the agent started (ISO 8601)" + }, + "status": { + "type": "string", + "description": "Agent status: running, `awaiting_input`, completing" + }, + "ticket_id": { + "type": "string", + "description": "Associated ticket ID (e.g., \"FEAT-042\")" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + } + } + }, + "ActiveAgentsResponse": { + "type": "object", + "description": "Response for active agents list", + "required": [ + "agents", + "count" + ], + "properties": { + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveAgentResponse" + }, + "description": "List of active agents" + }, + "count": { + "type": "integer", + "description": "Total count of active agents", + "minimum": 0 + } + } + }, + "AgentDetailResponse": { + "type": "object", + "description": "Full details for a single agent", + "required": [ + "id", + "ticket_id", + "ticket_type", + "project", + "status", + "started_at", + "last_activity", + "completed_steps", + "paired" + ], + "properties": { + "completed_steps": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Completed steps for this ticket" + }, + "current_step": { + "type": [ + "string", + "null" + ], + "description": "Current workflow step" + }, + "id": { + "type": "string", + "description": "Agent ID (UUID)" + }, + "last_activity": { + "type": "string", + "description": "Last activity timestamp (ISO 8601)" + }, + "launch_mode": { + "type": [ + "string", + "null" + ], + "description": "Launch mode: \"default\", \"yolo\", \"docker\", \"docker-yolo\"" + }, + "llm_model": { + "type": [ + "string", + "null" + ], + "description": "LLM model alias (e.g., \"opus\", \"sonnet\", \"gpt-4o\")" + }, + "llm_tool": { + "type": [ + "string", + "null" + ], + "description": "LLM tool used (e.g., \"claude\", \"gemini\", \"codex\")" + }, + "paired": { + "type": "boolean", + "description": "Whether this is a paired (interactive) agent" + }, + "pr_status": { + "type": [ + "string", + "null" + ], + "description": "Last known PR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")" + }, + "pr_url": { + "type": [ + "string", + "null" + ], + "description": "PR URL if created during \"pr\" step" + }, + "project": { + "type": "string", + "description": "Project being worked on" + }, + "review_state": { + "type": [ + "string", + "null" + ], + "description": "Review state for `awaiting_input` agents" + }, + "session_wrapper": { + "type": [ + "string", + "null" + ], + "description": "Which session wrapper is in use: \"tmux\", \"vscode\", \"cmux\", or \"zellij\"" + }, + "started_at": { + "type": "string", + "description": "When the agent started (ISO 8601)" + }, + "status": { + "type": "string", + "description": "Agent status: running, `awaiting_input`, completing, orphaned" + }, + "ticket_id": { + "type": "string", + "description": "Associated ticket ID (e.g., \"FEAT-042\")" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + }, + "worktree_path": { + "type": [ + "string", + "null" + ], + "description": "Path to the git worktree for this ticket" + } + } + }, + "AssessTicketResponse": { + "type": "object", + "description": "Response from creating an ASSESS ticket", + "required": [ + "ticket_id", + "ticket_path", + "project_name" + ], + "properties": { + "project_name": { + "type": "string", + "description": "Project name that was assessed" + }, + "ticket_id": { + "type": "string", + "description": "Ticket ID (e.g., \"ASSESS-1234\")" + }, + "ticket_path": { + "type": "string", + "description": "Path to the created ticket file" + } + } + }, + "CollectionResponse": { + "type": "object", + "description": "Response for a collection", + "required": [ + "name", + "description", + "types", + "is_active" + ], + "properties": { + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CreateDelegatorFromToolRequest": { + "type": "object", + "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.", + "required": [ + "tool_name" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ], + "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias." + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`." + }, + "tool_name": { + "type": "string", + "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")" + } + } + }, + "CreateDelegatorRequest": { + "type": "object", + "description": "Request to create a new delegator", + "required": [ + "name", + "llm_tool", + "model" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "llm_tool": { + "type": "string", + "description": "LLM tool name (must match a detected tool)" + }, + "model": { + "type": "string", + "description": "Model alias" + }, + "model_properties": { + "type": "object", + "description": "Arbitrary model properties", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": "string", + "description": "Unique name for the delegator" + } + } + }, + "CreateFieldRequest": { + "type": "object", + "description": "Request to create a field", + "required": [ + "name", "description" ], "properties": { - "default": { - "type": [ - "string", - "null" - ] + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "field_type": { + "type": "string" + }, + "max_length": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "name": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "user_editable": { + "type": "boolean" + } + } + }, + "CreateIssueTypeRequest": { + "type": "object", + "description": "Request to create a new issue type", + "required": [ + "key", + "name", + "description", + "glyph", + "steps" + ], + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateFieldRequest" + } + }, + "glyph": { + "type": "string" + }, + "key": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_required": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateStepRequest" + } + } + } + }, + "CreateModelServerRequest": { + "type": "object", + "description": "Request to create a new model server", + "required": [ + "name", + "kind" + ], + "properties": { + "api_key_env": { + "type": [ + "string", + "null" + ], + "description": "Name of an env var providing the API key" + }, + "base_url": { + "type": [ + "string", + "null" + ], + "description": "Base URL of the inference endpoint" + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "extra_env": { + "type": "object", + "description": "Additional environment variables", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + }, + "name": { + "type": "string", + "description": "Unique name for this model server" + } + } + }, + "CreateStepRequest": { + "type": "object", + "description": "Request to create a step", + "required": [ + "name", + "prompt" + ], + "properties": { + "allowed_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "next_step": { + "type": [ + "string", + "null" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "permission_mode": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "review_type": { + "type": "string", + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + } + } + }, + "DefaultLlmResponse": { + "type": "object", + "description": "Response with the current default LLM tool and model", + "required": [ + "tool", + "model" + ], + "properties": { + "model": { + "type": "string", + "description": "Default model alias (empty string if not set)" + }, + "tool": { + "type": "string", + "description": "Default tool name (empty string if not set)" + } + } + }, + "DelegatorLaunchConfigDto": { + "type": "object", + "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.", + "properties": { + "create_branch": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to create a git branch for the ticket (None = default behavior)" + }, + "docker": { + "type": [ + "boolean", + "null" + ], + "description": "Run in docker container (None = use global `launch.docker.enabled`)" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional CLI flags" + }, + "operator_relay": { + "type": [ + "boolean", + "null" + ], + "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)" + }, + "permission_mode": { + "type": [ + "string", + "null" + ], + "description": "Permission mode override" + }, + "prompt_prefix": { + "type": [ + "string", + "null" + ], + "description": "Prompt text to prepend before the generated step prompt" + }, + "prompt_suffix": { + "type": [ + "string", + "null" + ], + "description": "Prompt text to append after the generated step prompt" + }, + "use_worktrees": { + "type": [ + "boolean", + "null" + ], + "description": "Override global `git.use_worktrees` (None = use global setting)" + }, + "yolo": { + "type": "boolean", + "description": "Run in YOLO mode" + } + } + }, + "DelegatorResponse": { + "type": "object", + "description": "Response for a single delegator", + "required": [ + "name", + "llm_tool", + "model", + "model_properties" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "llm_tool": { + "type": "string", + "description": "LLM tool name (e.g., \"claude\")" + }, + "model": { + "type": "string", + "description": "Model alias (e.g., \"opus\")" + }, + "model_properties": { + "type": "object", + "description": "Arbitrary model properties", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": "string", + "description": "Unique name" + } + } + }, + "DelegatorsResponse": { + "type": "object", + "description": "Response listing all delegators", + "required": [ + "delegators", + "total" + ], + "properties": { + "delegators": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DelegatorResponse" + }, + "description": "List of delegators" + }, + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 + } + } + }, + "DetectedTool": { + "type": "object", + "description": "A detected CLI tool (e.g., claude binary)", + "required": [ + "name", + "path", + "version" + ], + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ToolCapabilities", + "description": "Tool capabilities" + }, + "command_template": { + "type": "string", + "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" + }, + "min_version": { + "type": [ + "string", + "null" + ], + "description": "Minimum required version for Operator compatibility" + }, + "model_aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])" + }, + "name": { + "type": "string", + "description": "Tool name (e.g., \"claude\")" + }, + "path": { + "type": "string", + "description": "Path to the binary" + }, + "version": { + "type": "string", + "description": "Version string" + }, + "version_ok": { + "type": "boolean", + "description": "Whether the installed version meets the minimum requirement" + }, + "yolo_flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CLI flags for YOLO (auto-accept) mode" + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Error response body", + "required": [ + "error", + "message" + ], + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ExternalIssueTypeSummary": { + "type": "object", + "description": "Summary of an issue type from an external kanban provider (Jira, Linear)", + "required": [ + "id", + "name" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the issue type" + }, + "icon_url": { + "type": [ + "string", + "null" + ], + "description": "Icon/avatar URL from the provider" + }, + "id": { + "type": "string", + "description": "Provider-specific unique identifier" + }, + "name": { + "type": "string", + "description": "Issue type name (e.g., \"Bug\", \"Story\", \"Task\")" + } + } + }, + "FieldResponse": { + "type": "object", + "description": "Response for a field", + "required": [ + "name", + "description", + "field_type", + "required", + "user_editable" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "field_type": { + "type": "string" + }, + "max_length": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "name": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "user_editable": { + "type": "boolean" + } + } + }, + "GithubCredentials": { + "type": "object", + "description": "Ephemeral GitHub Projects credentials supplied by a client during onboarding.\n\nThe token must have `project` (or `read:project`) scope. A repo-only token\n(the kind used for `GITHUB_TOKEN` and operator's git provider) will be\nrejected at validation time with a friendly \"lacks `project` scope\" error.", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "GitHub PAT, fine-grained PAT, or app installation token" + } + } + }, + "GithubProjectInfoDto": { + "type": "object", + "description": "A GitHub Project v2 surfaced during onboarding for project picker UIs.", + "required": [ + "node_id", + "number", + "title", + "owner_login", + "owner_kind" + ], + "properties": { + "node_id": { + "type": "string", + "description": "`GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Project number (e.g., 42) within the owner" + }, + "owner_kind": { + "type": "string", + "description": "\"Organization\" or \"User\"" + }, + "owner_login": { + "type": "string", + "description": "Owner login (org or user name)" + }, + "title": { + "type": "string", + "description": "Human-readable project title" + } + } + }, + "GithubSessionEnv": { + "type": "object", + "description": "GitHub Projects session env body — includes the actual secret to set in env.", + "required": [ + "token", + "api_key_env" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "GithubValidationDetailsDto": { + "type": "object", + "description": "GitHub-specific validation details (returned on success).", + "required": [ + "user_login", + "user_id", + "projects", + "resolved_env_var" + ], + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GithubProjectInfoDto" + }, + "description": "All Projects v2 visible to the token (across viewer + organizations)" + }, + "resolved_env_var": { + "type": "string", + "description": "The env var name the validated token came from. Used by clients to\ndisplay \"Connected via `OPERATOR_GITHUB_TOKEN`\" so users can rotate the\nright token. See Token Disambiguation in the kanban github docs." + }, + "user_id": { + "type": "string", + "description": "Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`)" + }, + "user_login": { + "type": "string", + "description": "Authenticated user's login (e.g., \"octocat\")" + } + } + }, + "HealthResponse": { + "type": "object", + "description": "Health check response", + "required": [ + "status", + "version" + ], + "properties": { + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "IssueTypeResponse": { + "type": "object", + "description": "Response for a single issue type", + "required": [ + "key", + "name", + "description", + "mode", + "glyph", + "project_required", + "source", + "fields", + "steps" + ], + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldResponse" + } + }, + "glyph": { + "type": "string" + }, + "key": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_required": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StepResponse" + } + } + } + }, + "IssueTypeSummary": { + "type": "object", + "description": "Summary response for listing issue types", + "required": [ + "key", + "name", + "description", + "mode", + "glyph", + "source", + "stepCount" + ], + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "glyph": { + "type": "string" + }, + "key": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "stepCount": { + "type": "integer", + "minimum": 0 + } + } + }, + "JiraCredentials": { + "type": "object", + "description": "Ephemeral Jira credentials supplied by a client during onboarding.\n\nThese are never persisted to disk by the onboarding endpoints that take\nthis struct — the actual secret stays in the env var named in\n`api_key_env` once set via `/api/v1/kanban/session-env`.", + "required": [ + "domain", + "email", + "api_token" + ], + "properties": { + "api_token": { + "type": "string", + "description": "API token / personal access token" }, - "description": { + "domain": { + "type": "string", + "description": "Jira Cloud domain (e.g., \"acme.atlassian.net\")" + }, + "email": { + "type": "string", + "description": "Atlassian account email for Basic Auth" + } + } + }, + "JiraSessionEnv": { + "type": "object", + "description": "Jira session env body — includes the actual secret to set in env.", + "required": [ + "domain", + "email", + "api_token", + "api_key_env" + ], + "properties": { + "api_key_env": { "type": "string" }, - "field_type": { + "api_token": { "type": "string" }, - "max_length": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "domain": { + "type": "string" }, - "name": { + "email": { "type": "string" + } + } + }, + "JiraValidationDetailsDto": { + "type": "object", + "description": "Jira-specific validation details (returned on success).", + "required": [ + "account_id", + "display_name" + ], + "properties": { + "account_id": { + "type": "string", + "description": "Atlassian accountId (used as `sync_user_id`)" }, - "options": { + "display_name": { + "type": "string", + "description": "User display name" + } + } + }, + "KanbanBoardResponse": { + "type": "object", + "description": "Kanban board response with tickets grouped by column", + "required": [ + "queue", + "running", + "awaiting", + "done", + "total_count", + "last_updated" + ], + "properties": { + "awaiting": { "type": "array", "items": { - "type": "string" - } + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Tickets awaiting review or input" }, - "placeholder": { - "type": [ - "string", - "null" - ] + "done": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Completed tickets" }, - "required": { - "type": "boolean" + "last_updated": { + "type": "string", + "description": "ISO 8601 timestamp of last data refresh" }, - "user_editable": { - "type": "boolean" + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Tickets in queue (not yet started)" + }, + "running": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Tickets currently being worked on" + }, + "total_count": { + "type": "integer", + "description": "Total ticket count across all columns", + "minimum": 0 } } }, - "CreateIssueTypeRequest": { + "KanbanIssueTypeResponse": { "type": "object", - "description": "Request to create a new issue type", + "description": "A synced kanban issue type from the persisted catalog.", "required": [ - "key", + "id", "name", - "description", - "glyph", - "steps" + "provider", + "project", + "source_kind", + "synced_at" ], "properties": { - "color": { + "description": { "type": [ "string", "null" - ] + ], + "description": "Description from the provider" }, - "description": { - "type": "string" + "icon_url": { + "type": [ + "string", + "null" + ], + "description": "Icon/avatar URL from the provider" }, - "fields": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateFieldRequest" - } + "id": { + "type": "string", + "description": "Provider-specific ID (Jira type ID, Linear label ID)" }, - "glyph": { - "type": "string" + "name": { + "type": "string", + "description": "Display name (e.g., \"Bug\", \"Story\", \"Task\")" }, - "key": { + "project": { + "type": "string", + "description": "Project/team key" + }, + "provider": { + "type": "string", + "description": "Provider name (\"jira\", \"linear\", or \"github\")" + }, + "source_kind": { + "type": "string", + "description": "What this type represents in the provider (\"issuetype\" or \"label\")" + }, + "synced_at": { + "type": "string", + "description": "ISO 8601 timestamp of last sync" + } + } + }, + "KanbanProjectInfo": { + "type": "object", + "description": "A project/team entry returned by `list_projects`.", + "required": [ + "id", + "key", + "name" + ], + "properties": { + "id": { "type": "string" }, - "mode": { + "key": { "type": "string" }, "name": { "type": "string" + } + } + }, + "KanbanProviderKind": { + "type": "string", + "description": "Which kanban provider an onboarding request targets.", + "enum": [ + "jira", + "linear", + "github" + ] + }, + "KanbanSyncResponse": { + "type": "object", + "description": "Response for kanban sync operations", + "required": [ + "created", + "skipped", + "errors", + "total_processed" + ], + "properties": { + "created": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ticket IDs that were created" }, - "project_required": { - "type": "boolean" + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Error messages for failed syncs" }, - "steps": { + "skipped": { "type": "array", "items": { - "$ref": "#/components/schemas/CreateStepRequest" - } + "type": "string" + }, + "description": "Ticket IDs that were skipped (already exist)" + }, + "total_processed": { + "type": "integer", + "description": "Total count of issues processed", + "minimum": 0 } } }, - "CreateModelServerRequest": { + "KanbanTicketCard": { "type": "object", - "description": "Request to create a new model server", + "description": "A ticket card for the kanban board", "required": [ - "name", - "kind" + "id", + "summary", + "ticket_type", + "project", + "status", + "step", + "priority", + "timestamp" ], "properties": { - "api_key_env": { + "id": { + "type": "string", + "description": "Ticket ID (e.g., \"FEAT-7598\")" + }, + "priority": { + "type": "string", + "description": "Priority: P0-critical, P1-high, P2-medium, P3-low" + }, + "project": { + "type": "string", + "description": "Project name" + }, + "status": { + "type": "string", + "description": "Current status: queued, running, awaiting, completed" + }, + "step": { + "type": "string", + "description": "Current step name" + }, + "step_display_name": { "type": [ "string", "null" ], - "description": "Name of an env var providing the API key" + "description": "Human-readable step name" }, - "base_url": { + "summary": { + "type": "string", + "description": "Ticket summary/title" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + }, + "timestamp": { + "type": "string", + "description": "Timestamp for sorting (YYYYMMDD-HHMM format)" + } + } + }, + "LaunchTicketRequest": { + "type": "object", + "description": "Request to launch a ticket", + "properties": { + "delegator": { "type": [ "string", "null" ], - "description": "Base URL of the inference endpoint" + "description": "Named delegator to use (takes precedence over provider/model)" }, - "display_name": { + "model": { "type": [ "string", "null" ], - "description": "Optional display name for UI" + "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator" }, - "extra_env": { - "type": "object", - "description": "Additional environment variables", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } + "provider": { + "type": [ + "string", + "null" + ], + "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator" }, - "kind": { - "type": "string", - "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + "resume_session_id": { + "type": [ + "string", + "null" + ], + "description": "Existing session ID to resume (for continuing from where it left off)" }, - "name": { - "type": "string", - "description": "Unique name for this model server" + "retry_reason": { + "type": [ + "string", + "null" + ], + "description": "Feedback for relaunch (what went wrong on previous attempt)" + }, + "wrapper": { + "type": [ + "string", + "null" + ], + "description": "Session wrapper type: \"vscode\", \"tmux\", \"cmux\", \"terminal\"" + }, + "yolo_mode": { + "type": "boolean", + "description": "Run in YOLO mode (auto-accept all prompts)" } } }, - "CreateStepRequest": { + "LaunchTicketResponse": { "type": "object", - "description": "Request to create a step", + "description": "Response from launching a ticket", "required": [ - "name", - "prompt" + "agent_id", + "ticket_id", + "working_directory", + "command", + "terminal_name", + "tmux_session_name", + "session_id", + "worktree_created" ], "properties": { - "allowed_tools": { - "type": "array", - "items": { - "type": "string" - } + "agent_id": { + "type": "string", + "description": "Agent ID assigned to this launch" + }, + "branch": { + "type": [ + "string", + "null" + ], + "description": "Branch name (if worktree was created)" + }, + "command": { + "type": "string", + "description": "Command to execute in terminal" }, - "display_name": { + "session_context_ref": { "type": [ "string", "null" - ] + ], + "description": "Session context reference (e.g. cmux workspace, zellij session)" }, - "name": { - "type": "string" + "session_id": { + "type": "string", + "description": "Session UUID for the LLM tool" }, - "next_step": { + "session_window_ref": { "type": [ "string", "null" - ] + ], + "description": "Session window reference ID (e.g. cmux window, tmux session)" }, - "outputs": { - "type": "array", - "items": { - "type": "string" - } + "session_wrapper": { + "type": [ + "string", + "null" + ], + "description": "Which session wrapper was used: \"tmux\", \"vscode\", or \"cmux\"" }, - "permission_mode": { - "type": "string" + "terminal_name": { + "type": "string", + "description": "Terminal name to use (same value as `tmux_session_name`)" }, - "prompt": { - "type": "string" + "ticket_id": { + "type": "string", + "description": "Ticket ID that was launched" }, - "review_type": { + "tmux_session_name": { "type": "string", - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)" + }, + "working_directory": { + "type": "string", + "description": "Working directory (worktree if created, else project path)" + }, + "worktree_created": { + "type": "boolean", + "description": "Whether a worktree was created" } } }, - "DefaultLlmResponse": { + "LinearCredentials": { "type": "object", - "description": "Response with the current default LLM tool and model", + "description": "Ephemeral Linear credentials supplied by a client during onboarding.", "required": [ - "tool", - "model" + "api_key" ], "properties": { - "model": { + "api_key": { "type": "string", - "description": "Default model alias (empty string if not set)" + "description": "Linear API key (prefixed `lin_api_`)" + } + } + }, + "LinearSessionEnv": { + "type": "object", + "description": "Linear session env body — includes the actual secret to set in env.", + "required": [ + "api_key", + "api_key_env" + ], + "properties": { + "api_key": { + "type": "string" }, - "tool": { - "type": "string", - "description": "Default tool name (empty string if not set)" + "api_key_env": { + "type": "string" } } }, - "DelegatorLaunchConfigDto": { + "LinearTeamInfoDto": { "type": "object", - "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.", + "description": "A Linear team exposed to onboarding clients for project selection.", + "required": [ + "id", + "key", + "name" + ], "properties": { - "create_branch": { - "type": [ - "boolean", - "null" - ], - "description": "Whether to create a git branch for the ticket (None = default behavior)" + "id": { + "type": "string" }, - "docker": { - "type": [ - "boolean", - "null" - ], - "description": "Run in docker container (None = use global `launch.docker.enabled`)" + "key": { + "type": "string" }, - "flags": { + "name": { + "type": "string" + } + } + }, + "LinearValidationDetailsDto": { + "type": "object", + "description": "Linear-specific validation details (returned on success).", + "required": [ + "user_id", + "user_name", + "org_name", + "teams" + ], + "properties": { + "org_name": { + "type": "string" + }, + "teams": { "type": "array", "items": { - "type": "string" - }, - "description": "Additional CLI flags" - }, - "operator_relay": { - "type": [ - "boolean", - "null" - ], - "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)" - }, - "permission_mode": { - "type": [ - "string", - "null" - ], - "description": "Permission mode override" - }, - "prompt_prefix": { - "type": [ - "string", - "null" - ], - "description": "Prompt text to prepend before the generated step prompt" - }, - "prompt_suffix": { - "type": [ - "string", - "null" - ], - "description": "Prompt text to append after the generated step prompt" + "$ref": "#/components/schemas/LinearTeamInfoDto" + } }, - "use_worktrees": { - "type": [ - "boolean", - "null" - ], - "description": "Override global `git.use_worktrees` (None = use global setting)" + "user_id": { + "type": "string", + "description": "Linear viewer user ID (used as `sync_user_id`)" }, - "yolo": { - "type": "boolean", - "description": "Run in YOLO mode" + "user_name": { + "type": "string" } } }, - "DelegatorResponse": { + "ListKanbanProjectsRequest": { "type": "object", - "description": "Response for a single delegator", + "description": "Request to list projects/teams from a provider using ephemeral creds.", "required": [ - "name", - "llm_tool", - "model", - "model_properties" + "provider" ], "properties": { - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name" - }, - "launch_config": { + "github": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/DelegatorLaunchConfigDto", - "description": "Optional launch configuration" + "$ref": "#/components/schemas/GithubCredentials" } ] }, - "llm_tool": { - "type": "string", - "description": "LLM tool name (e.g., \"claude\")" + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraCredentials" + } + ] }, - "model": { - "type": "string", - "description": "Model alias (e.g., \"opus\")" + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearCredentials" + } + ] }, - "model_properties": { - "type": "object", - "description": "Arbitrary model properties", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "ListKanbanProjectsResponse": { + "type": "object", + "description": "Response wrapper for list-projects (wrapped for utoipa compatibility).", + "required": [ + "projects" + ], + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanProjectInfo" } - }, - "model_server": { - "type": [ - "string", - "null" - ], - "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." - }, - "name": { - "type": "string", - "description": "Unique name" } } }, - "DelegatorsResponse": { + "LlmToolsResponse": { "type": "object", - "description": "Response listing all delegators", + "description": "Response listing detected LLM tools", "required": [ - "delegators", + "tools", "total" ], "properties": { - "delegators": { + "tools": { "type": "array", "items": { - "$ref": "#/components/schemas/DelegatorResponse" + "$ref": "#/components/schemas/DetectedTool" }, - "description": "List of delegators" + "description": "Detected CLI tools with model aliases and capabilities" }, "total": { "type": "integer", @@ -1593,510 +3612,581 @@ } } }, - "DetectedTool": { + "McpDescriptorResponse": { "type": "object", - "description": "A detected CLI tool (e.g., claude binary)", + "description": "MCP server descriptor for client discovery", "required": [ - "name", - "path", - "version" + "server_name", + "server_id", + "version", + "transport_url", + "label" ], "properties": { - "capabilities": { - "$ref": "#/components/schemas/ToolCapabilities", - "description": "Tool capabilities" - }, - "command_template": { + "label": { "type": "string", - "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" + "description": "Human-readable label for the server" }, - "min_version": { + "openapi_url": { "type": [ "string", "null" ], - "description": "Minimum required version for Operator compatibility" - }, - "model_aliases": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])" - }, - "name": { - "type": "string", - "description": "Tool name (e.g., \"claude\")" + "description": "URL of the OpenAPI spec for reference" }, - "path": { + "server_id": { "type": "string", - "description": "Path to the binary" + "description": "Unique server identifier (e.g. \"operator-mcp\")" }, - "version": { + "server_name": { "type": "string", - "description": "Version string" + "description": "Server name used in MCP registration (e.g. \"operator\")" }, - "version_ok": { - "type": "boolean", - "description": "Whether the installed version meets the minimum requirement" + "stdio": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StdioCommand", + "description": "Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`.\nClients may spawn this as a subprocess instead of using `transport_url`." + } + ] }, - "yolo_flags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "CLI flags for YOLO (auto-accept) mode" - } - } - }, - "ErrorResponse": { - "type": "object", - "description": "Error response body", - "required": [ - "error", - "message" - ], - "properties": { - "error": { - "type": "string" + "transport_url": { + "type": "string", + "description": "Full URL of the MCP SSE transport endpoint" }, - "message": { - "type": "string" + "version": { + "type": "string", + "description": "Server version from Cargo.toml" } } }, - "FieldResponse": { + "ModelServerResponse": { "type": "object", - "description": "Response for a field", + "description": "Response for a single model server", "required": [ "name", - "description", - "field_type", - "required", - "user_editable" + "kind", + "extra_env", + "user_declared" ], "properties": { - "default": { + "api_key_env": { "type": [ "string", "null" - ] - }, - "description": { - "type": "string" - }, - "field_type": { - "type": "string" + ], + "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)" }, - "max_length": { + "base_url": { "type": [ - "integer", + "string", "null" ], - "minimum": 0 + "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)" }, - "name": { - "type": "string" + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" }, - "options": { - "type": "array", - "items": { + "extra_env": { + "type": "object", + "description": "Additional environment variables set when spawning agents that use this server", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { "type": "string" } }, - "placeholder": { - "type": [ - "string", - "null" - ] + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" }, - "required": { - "type": "boolean" + "name": { + "type": "string", + "description": "Unique name (e.g., \"ollama-local\")" }, - "user_editable": { - "type": "boolean" + "user_declared": { + "type": "boolean", + "description": "Whether this is a user-declared server (true) or an implicit builtin (false)" } } }, - "HealthResponse": { + "ModelServersResponse": { "type": "object", - "description": "Health check response", + "description": "Response listing all model servers (declared + implicit builtins)", "required": [ - "status", - "version" + "servers", + "total" ], "properties": { - "status": { - "type": "string" + "servers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelServerResponse" + }, + "description": "List of model servers" }, - "version": { - "type": "string" + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 } } }, - "IssueTypeResponse": { + "NextStepInfo": { "type": "object", - "description": "Response for a single issue type", + "description": "Information about the next step in the workflow", "required": [ - "key", "name", - "description", - "mode", - "glyph", - "project_required", - "source", - "fields", - "steps" + "display_name", + "review_type" ], "properties": { - "color": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": "string" - }, - "fields": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FieldResponse" - } - }, - "glyph": { - "type": "string" - }, - "key": { - "type": "string" - }, - "mode": { - "type": "string" + "display_name": { + "type": "string", + "description": "Display name for the step" }, "name": { - "type": "string" - }, - "project_required": { - "type": "boolean" + "type": "string", + "description": "Step name" }, - "source": { - "type": "string" + "prompt": { + "type": [ + "string", + "null" + ], + "description": "Prompt template for the step" }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StepResponse" - } + "review_type": { + "type": "string", + "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\"" } } }, - "IssueTypeSummary": { + "OperatorOutput": { "type": "object", - "description": "Summary response for listing issue types", + "description": "Standardized agent output for progress tracking and step transitions.\n\nAgents output a status block in their response which is parsed into this structure.\nUsed for progress tracking, loop detection, and intelligent step transitions.", "required": [ - "key", - "name", - "description", - "mode", - "glyph", - "source", - "stepCount" + "status", + "exit_signal" ], "properties": { - "color": { + "blockers": { "type": [ - "string", + "array", "null" - ] - }, - "description": { - "type": "string" - }, - "glyph": { - "type": "string" - }, - "key": { - "type": "string" + ], + "items": { + "type": "string" + }, + "description": "Issues preventing progress (signals intervention needed)" }, - "mode": { - "type": "string" + "confidence": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Agent's confidence in completion (0-100%)", + "minimum": 0 }, - "name": { - "type": "string" + "error_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Number of errors encountered", + "minimum": 0 }, - "source": { - "type": "string" + "exit_signal": { + "type": "boolean", + "description": "Agent signals done with step (true) or more work remains (false)" }, - "stepCount": { - "type": "integer", - "minimum": 0 - } - } - }, - "LaunchTicketRequest": { - "type": "object", - "description": "Request to launch a ticket", - "properties": { - "delegator": { + "files_modified": { "type": [ - "string", + "integer", "null" ], - "description": "Named delegator to use (takes precedence over provider/model)" + "format": "int32", + "description": "Number of files changed this iteration", + "minimum": 0 }, - "model": { + "recommendation": { "type": [ "string", "null" ], - "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator" + "description": "Suggested next action (max 200 chars)" }, - "provider": { + "status": { + "type": "string", + "description": "Current work status: `in_progress`, complete, blocked, failed" + }, + "summary": { "type": [ "string", "null" ], - "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator" + "description": "Brief description of work done (max 500 chars)" }, - "resume_session_id": { + "tasks_completed": { "type": [ - "string", + "integer", "null" ], - "description": "Existing session ID to resume (for continuing from where it left off)" + "format": "int32", + "description": "Number of sub-tasks completed this iteration", + "minimum": 0 }, - "retry_reason": { + "tasks_remaining": { "type": [ - "string", + "integer", "null" ], - "description": "Feedback for relaunch (what went wrong on previous attempt)" + "format": "int32", + "description": "Estimated remaining sub-tasks", + "minimum": 0 }, - "wrapper": { + "tests_status": { "type": [ "string", "null" ], - "description": "Session wrapper type: \"vscode\", \"tmux\", \"cmux\", \"terminal\"" - }, - "yolo_mode": { - "type": "boolean", - "description": "Run in YOLO mode (auto-accept all prompts)" + "description": "Test suite status: passing, failing, skipped, `not_run`" } } }, - "LaunchTicketResponse": { + "ProjectSummary": { "type": "object", - "description": "Response from launching a ticket", + "description": "Summary of a project with analysis data", "required": [ - "agent_id", - "ticket_id", - "working_directory", - "command", - "terminal_name", - "tmux_session_name", - "session_id", - "worktree_created" + "project_name", + "project_path", + "exists", + "has_catalog_info", + "has_project_context", + "languages", + "frameworks", + "databases", + "ports", + "env_var_count", + "entry_point_count", + "commands" ], "properties": { - "agent_id": { - "type": "string", - "description": "Agent ID assigned to this launch" + "commands": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available command names (start, dev, test, etc.)" }, - "branch": { + "databases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Database display names" + }, + "entry_point_count": { + "type": "integer", + "description": "Number of entry points", + "minimum": 0 + }, + "env_var_count": { + "type": "integer", + "description": "Number of environment variables", + "minimum": 0 + }, + "exists": { + "type": "boolean", + "description": "Whether the project directory exists on disk" + }, + "frameworks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Framework display names" + }, + "has_catalog_info": { + "type": "boolean", + "description": "Whether catalog-info.yaml exists" + }, + "has_docker": { "type": [ - "string", + "boolean", "null" ], - "description": "Branch name (if worktree was created)" + "description": "Has Dockerfile or docker-compose" }, - "command": { - "type": "string", - "description": "Command to execute in terminal" + "has_project_context": { + "type": "boolean", + "description": "Whether project-context.json exists" }, - "session_context_ref": { + "has_tests": { "type": [ - "string", + "boolean", "null" ], - "description": "Session context reference (e.g. cmux workspace, zellij session)" - }, - "session_id": { - "type": "string", - "description": "Session UUID for the LLM tool" + "description": "Has test frameworks detected" }, - "session_window_ref": { + "kind": { "type": [ "string", "null" ], - "description": "Session window reference ID (e.g. cmux window, tmux session)" + "description": "Primary Kind from `kind_assessment` (e.g., \"microservice\")" }, - "session_wrapper": { + "kind_confidence": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Kind confidence score 0.0-1.0" + }, + "kind_tier": { "type": [ "string", "null" ], - "description": "Which session wrapper was used: \"tmux\", \"vscode\", or \"cmux\"" + "description": "Taxonomy tier (e.g., \"engines\")" }, - "terminal_name": { - "type": "string", - "description": "Terminal name to use (same value as `tmux_session_name`)" + "languages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Language display names" }, - "ticket_id": { - "type": "string", - "description": "Ticket ID that was launched" + "ports": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "Detected port numbers" }, - "tmux_session_name": { + "project_name": { "type": "string", - "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)" + "description": "Project directory name" }, - "working_directory": { + "project_path": { "type": "string", - "description": "Working directory (worktree if created, else project path)" - }, - "worktree_created": { - "type": "boolean", - "description": "Whether a worktree was created" + "description": "Absolute path to project root" } } }, - "LlmToolsResponse": { + "QueueByType": { "type": "object", - "description": "Response listing detected LLM tools", + "description": "Ticket counts by type for queue status", "required": [ - "tools", - "total" + "inv", + "fix", + "feat", + "spike" ], "properties": { - "tools": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DetectedTool" - }, - "description": "Detected CLI tools with model aliases and capabilities" + "feat": { + "type": "integer", + "minimum": 0 }, - "total": { + "fix": { + "type": "integer", + "minimum": 0 + }, + "inv": { + "type": "integer", + "minimum": 0 + }, + "spike": { "type": "integer", - "description": "Total count", "minimum": 0 } } }, - "McpDescriptorResponse": { + "QueueControlResponse": { "type": "object", - "description": "MCP server descriptor for client discovery", + "description": "Response for queue pause/resume operations", "required": [ - "server_name", - "server_id", - "version", - "transport_url", - "label" + "paused", + "message" ], "properties": { - "label": { + "message": { "type": "string", - "description": "Human-readable label for the server" + "description": "Human-readable message about the operation" }, - "openapi_url": { - "type": [ - "string", - "null" - ], - "description": "URL of the OpenAPI spec for reference" + "paused": { + "type": "boolean", + "description": "Whether the queue is currently paused" + } + } + }, + "QueueStatusResponse": { + "type": "object", + "description": "Queue status response with ticket counts", + "required": [ + "queued", + "in_progress", + "awaiting", + "completed", + "by_type" + ], + "properties": { + "awaiting": { + "type": "integer", + "description": "Tickets awaiting review or input", + "minimum": 0 }, - "server_id": { - "type": "string", - "description": "Unique server identifier (e.g. \"operator-mcp\")" + "by_type": { + "$ref": "#/components/schemas/QueueByType", + "description": "Breakdown by ticket type" }, - "server_name": { - "type": "string", - "description": "Server name used in MCP registration (e.g. \"operator\")" + "completed": { + "type": "integer", + "description": "Completed tickets (today)", + "minimum": 0 }, - "transport_url": { - "type": "string", - "description": "Full URL of the MCP SSE transport endpoint" + "in_progress": { + "type": "integer", + "description": "Tickets currently being worked on", + "minimum": 0 }, - "version": { + "queued": { + "type": "integer", + "description": "Tickets waiting in queue", + "minimum": 0 + } + } + }, + "RejectReviewRequest": { + "type": "object", + "description": "Request to reject an agent's review", + "required": [ + "reason" + ], + "properties": { + "reason": { "type": "string", - "description": "Server version from Cargo.toml" + "description": "Reason for rejection (feedback for the agent)" } } }, - "ModelServerResponse": { + "ReviewResponse": { "type": "object", - "description": "Response for a single model server", + "description": "Response for agent review operations (approve/reject)", "required": [ - "name", - "kind", - "extra_env", - "user_declared" + "agent_id", + "status", + "message" ], "properties": { - "api_key_env": { - "type": [ - "string", - "null" - ], - "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)" - }, - "base_url": { - "type": [ - "string", - "null" - ], - "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)" + "agent_id": { + "type": "string", + "description": "Agent ID that was reviewed" }, - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name for UI" + "message": { + "type": "string", + "description": "Human-readable message about the operation" }, - "extra_env": { - "type": "object", - "description": "Additional environment variables set when spawning agents that use this server", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" + "status": { + "type": "string", + "description": "Review status: \"approved\" or \"rejected\"" + } + } + }, + "SectionDto": { + "type": "object", + "description": "A status section with its health and child rows.", + "required": [ + "id", + "label", + "health", + "description", + "prerequisites", + "met", + "children" + ], + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SectionRowDto" } }, - "kind": { + "description": { + "type": "string" + }, + "health": { "type": "string", - "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + "description": "Health: \"green\" | \"yellow\" | \"red\" | \"gray\"." }, - "name": { + "id": { "type": "string", - "description": "Unique name (e.g., \"ollama-local\")" + "description": "Stable section id (e.g. \"config\", \"connections\", \"kanban\")." }, - "user_declared": { + "label": { + "type": "string" + }, + "met": { "type": "boolean", - "description": "Whether this is a user-declared server (true) or an implicit builtin (false)" + "description": "Whether all prerequisites are met. Sections are always returned (the web\nUI styles unmet ones as locked) rather than hidden by progressive disclosure." + }, + "prerequisites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Section ids that must be Green before this section is usable." } } }, - "ModelServersResponse": { + "SectionRowDto": { "type": "object", - "description": "Response listing all model servers (declared + implicit builtins)", + "description": "A child row within a status section.", "required": [ - "servers", - "total" + "id", + "depth", + "label", + "description", + "icon", + "health" ], "properties": { - "servers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ModelServerResponse" - }, - "description": "List of model servers" - }, - "total": { + "depth": { "type": "integer", - "description": "Total count", + "format": "int32", + "description": "Nesting depth within the section (1 = direct child, 2 = grandchild).\nLets clients rebuild the tree (e.g. LLM tools → model aliases).", "minimum": 0 + }, + "description": { + "type": "string" + }, + "health": { + "type": "string", + "description": "Health: \"green\" | \"yellow\" | \"red\" | \"gray\"." + }, + "icon": { + "type": "string", + "description": "Icon hint (e.g. \"check\", \"warning\", \"tool\", \"folder\")." + }, + "id": { + "type": "string", + "description": "Stable, section-scoped row id. Clients use it as a tree key and to route\nrow-specific commands without matching on the (mutable) display label.\nDynamic rows carry their entity key (issue-type key, project name);\nstatic rows carry a fixed slug (e.g. \"git-token\")." + }, + "label": { + "type": "string" } } }, @@ -2118,6 +4208,69 @@ } } }, + "SetKanbanSessionEnvRequest": { + "type": "object", + "description": "Request to set kanban-related env vars on the server for the current\nsession so subsequent `from_config` calls find the API key.", + "required": [ + "provider" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubSessionEnv" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraSessionEnv" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearSessionEnv" + } + ] + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "SetKanbanSessionEnvResponse": { + "type": "object", + "description": "Response from setting session env vars.\n\n`shell_export_block` uses `` placeholders, NOT the actual\nsecret — it is meant for the user to copy into their shell profile.", + "required": [ + "env_vars_set", + "shell_export_block" + ], + "properties": { + "env_vars_set": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names (not values) of env vars that were set in the server process." + }, + "shell_export_block": { + "type": "string", + "description": "Multi-line `export FOO=\"\"` block for the user to copy\ninto `~/.zshrc` / `~/.bashrc`." + } + } + }, "SkillEntry": { "type": "object", "description": "A single discovered skill file", @@ -2198,6 +4351,172 @@ } } }, + "StdioCommand": { + "type": "object", + "description": "Stdio entrypoint advertised in the descriptor when\n`[mcp].stdio_advertised = true`. Clients use this to spawn operator\nas an MCP subprocess instead of (or alongside) the SSE transport.", + "required": [ + "command", + "args", + "cwd" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Args to pass: typically `[\"mcp\"]`" + }, + "command": { + "type": "string", + "description": "Absolute path to the operator binary (the same binary serving this descriptor)" + }, + "cwd": { + "type": "string", + "description": "Working directory the client should set when spawning. Defaults to the\noperator process's current working directory." + } + } + }, + "StepCompleteRequest": { + "type": "object", + "description": "Request to report step completion (from opr8r wrapper)", + "required": [ + "exit_code", + "duration_secs" + ], + "properties": { + "duration_secs": { + "type": "integer", + "format": "int64", + "description": "Duration of the step in seconds", + "minimum": 0 + }, + "exit_code": { + "type": "integer", + "format": "int32", + "description": "Exit code from the LLM command" + }, + "output": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OperatorOutput", + "description": "Structured output from agent (parsed `OPERATOR_STATUS` block)" + } + ] + }, + "output_sample": { + "type": [ + "string", + "null" + ], + "description": "Sample of the output (first N chars for debugging)" + }, + "output_schema_errors": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "List of validation errors (if `output_valid` is false)" + }, + "output_valid": { + "type": "boolean", + "description": "Whether output validation passed (if schema was specified)" + }, + "session_id": { + "type": [ + "string", + "null" + ], + "description": "Session ID from the LLM session" + } + } + }, + "StepCompleteResponse": { + "type": "object", + "description": "Response from step completion endpoint", + "required": [ + "status", + "auto_proceed" + ], + "properties": { + "auto_proceed": { + "type": "boolean", + "description": "Whether to automatically proceed to the next step" + }, + "circuit_state": { + "type": "string", + "description": "Circuit breaker state: closed (normal), `half_open` (monitoring), open (halted)" + }, + "cumulative_errors": { + "type": "integer", + "format": "int32", + "description": "Cumulative errors across iterations", + "minimum": 0 + }, + "cumulative_files_modified": { + "type": "integer", + "format": "int32", + "description": "Cumulative files modified across iterations", + "minimum": 0 + }, + "iteration_count": { + "type": "integer", + "format": "int32", + "description": "How many times this step has run (for circuit breaker)", + "minimum": 0 + }, + "next_command": { + "type": [ + "string", + "null" + ], + "description": "Command to execute for the next step (opr8r wrapped)" + }, + "next_step": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NextStepInfo", + "description": "Information about the next step (if any)" + } + ] + }, + "output_valid": { + "type": "boolean", + "description": "Whether `OperatorOutput` was successfully parsed from agent output" + }, + "previous_recommendation": { + "type": [ + "string", + "null" + ], + "description": "Recommendation from previous step's `OperatorOutput`" + }, + "previous_summary": { + "type": [ + "string", + "null" + ], + "description": "Summary from previous step's `OperatorOutput`" + }, + "should_iterate": { + "type": "boolean", + "description": "Agent has more work (`exit_signal=false`) - indicates iteration needed" + }, + "status": { + "type": "string", + "description": "Status of the step: \"completed\", \"`awaiting_review`\", \"failed\", \"iterate\"" + } + } + }, "StepResponse": { "type": "object", "description": "Response for a step", @@ -2216,36 +4535,185 @@ "type": "string" } }, - "display_name": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "next_step": { + "type": [ + "string", + "null" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "permission_mode": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "review_type": { + "type": "string", + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + } + } + }, + "SyncKanbanIssueTypesResponse": { + "type": "object", + "description": "Response from syncing kanban issue types from a provider.", + "required": [ + "synced", + "types" + ], + "properties": { + "synced": { + "type": "integer", + "description": "Number of issue types synced", + "minimum": 0 + }, + "types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanIssueTypeResponse" + }, + "description": "The synced issue types" + } + } + }, + "TicketDetailResponse": { + "type": "object", + "description": "Full ticket details including content and metadata", + "required": [ + "id", + "summary", + "ticket_type", + "project", + "status", + "step", + "priority", + "timestamp", + "content", + "filename", + "filepath", + "sessions", + "step_delegators" + ], + "properties": { + "branch": { + "type": [ + "string", + "null" + ], + "description": "Git branch name" + }, + "content": { + "type": "string", + "description": "Full markdown content of the ticket" + }, + "external_id": { + "type": [ + "string", + "null" + ], + "description": "External issue ID from kanban provider" + }, + "external_provider": { + "type": [ + "string", + "null" + ], + "description": "Provider name (e.g., \"jira\", \"linear\")" + }, + "external_url": { + "type": [ + "string", + "null" + ], + "description": "URL to the issue in the external provider" + }, + "filename": { + "type": "string", + "description": "Ticket filename" + }, + "filepath": { + "type": "string", + "description": "Full filesystem path" + }, + "id": { + "type": "string", + "description": "Ticket ID (e.g., \"FEAT-7598\")" + }, + "priority": { + "type": "string", + "description": "Priority: P0-critical, P1-high, P2-medium, P3-low" + }, + "project": { + "type": "string", + "description": "Project name" + }, + "sessions": { + "type": "object", + "description": "Session IDs per step (`step_name` -> `session_uuid`)", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "status": { + "type": "string", + "description": "Current status: queued, running, awaiting, completed" + }, + "step": { + "type": "string", + "description": "Current step name" + }, + "step_delegators": { + "type": "object", + "description": "Delegator used per step (`step_name` -> `delegator_name`)", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "step_display_name": { "type": [ "string", "null" - ] + ], + "description": "Human-readable step name" }, - "name": { - "type": "string" + "summary": { + "type": "string", + "description": "Ticket summary/title" }, - "next_step": { + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + }, + "timestamp": { + "type": "string", + "description": "Timestamp (YYYYMMDD-HHMM format)" + }, + "worktree_path": { "type": [ "string", "null" - ] - }, - "outputs": { - "type": "array", - "items": { - "type": "string" - } - }, - "permission_mode": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "review_type": { - "type": "string", - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + ], + "description": "Path to git worktree (if created)" } } }, @@ -2377,6 +4845,305 @@ "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" } } + }, + "UpdateTicketStatusRequest": { + "type": "object", + "description": "Request to update a ticket's status", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "description": "Target status: queued, running, awaiting, done" + } + } + }, + "UpdateTicketStatusResponse": { + "type": "object", + "description": "Response from updating a ticket's status", + "required": [ + "id", + "previous_status", + "status", + "message" + ], + "properties": { + "id": { + "type": "string", + "description": "Ticket ID" + }, + "message": { + "type": "string", + "description": "Human-readable message" + }, + "previous_status": { + "type": "string", + "description": "Previous status before the update" + }, + "status": { + "type": "string", + "description": "New status after the update" + } + } + }, + "ValidateKanbanCredentialsRequest": { + "type": "object", + "description": "Request to validate kanban credentials without persisting them.", + "required": [ + "provider" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubCredentials" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraCredentials" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearCredentials" + } + ] + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "ValidateKanbanCredentialsResponse": { + "type": "object", + "description": "Response from validating kanban credentials.\n\n`valid: false` is returned for auth failures — never a 4xx/5xx HTTP\nstatus — so clients can display `error` inline without exception handling.", + "required": [ + "valid" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubValidationDetailsDto" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraValidationDetailsDto" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearValidationDetailsDto" + } + ] + }, + "valid": { + "type": "boolean" + } + } + }, + "WorkflowExportResponse": { + "type": "object", + "description": "Response for exporting a ticket to a Claude dynamic workflow (`.js`).", + "required": [ + "ticket_id", + "issuetype_key", + "suggested_filename", + "contents" + ], + "properties": { + "contents": { + "type": "string", + "description": "The generated `.js` workflow source." + }, + "issuetype_key": { + "type": "string", + "description": "The issue type key that supplied the step structure." + }, + "suggested_filename": { + "type": "string", + "description": "Suggested filename for saving the workflow (`.workflow.js`)." + }, + "ticket_id": { + "type": "string", + "description": "The ticket the workflow was generated from." + } + } + }, + "WriteGithubConfigBody": { + "type": "object", + "description": "Body for writing a GitHub Projects v2 config section.", + "required": [ + "owner", + "api_key_env", + "project_key", + "sync_user_id" + ], + "properties": { + "api_key_env": { + "type": "string", + "description": "Env var name where the project-scoped token is set\n(default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN`\n— see Token Disambiguation in the kanban github docs." + }, + "owner": { + "type": "string", + "description": "GitHub owner login (user or org), used as the workspace key" + }, + "project_key": { + "type": "string", + "description": "`GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`)" + }, + "sync_user_id": { + "type": "string", + "description": "Numeric GitHub `databaseId` of the user whose items to sync" + } + } + }, + "WriteJiraConfigBody": { + "type": "object", + "description": "Body for writing a Jira project config section.", + "required": [ + "domain", + "email", + "api_key_env", + "project_key", + "sync_user_id" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "email": { + "type": "string" + }, + "project_key": { + "type": "string" + }, + "sync_user_id": { + "type": "string" + } + } + }, + "WriteKanbanConfigRequest": { + "type": "object", + "description": "Request to write or upsert a kanban config section.\n\nThis endpoint does NOT take the secret — only the env var NAME\n(`api_key_env`). The secret is set via `/api/v1/kanban/session-env`.", + "required": [ + "provider" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteGithubConfigBody" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteJiraConfigBody" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteLinearConfigBody" + } + ] + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "WriteKanbanConfigResponse": { + "type": "object", + "description": "Response after writing a kanban config section.", + "required": [ + "written_path", + "section_header" + ], + "properties": { + "section_header": { + "type": "string", + "description": "Header of the top-level section that was upserted\n(e.g., `[kanban.jira.\"acme.atlassian.net\"]`)" + }, + "written_path": { + "type": "string", + "description": "Filesystem path that was written (e.g., \".tickets/operator/config.toml\")" + } + } + }, + "WriteLinearConfigBody": { + "type": "object", + "description": "Body for writing a Linear project/team config section.", + "required": [ + "workspace_key", + "api_key_env", + "project_key", + "sync_user_id" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "project_key": { + "type": "string" + }, + "sync_user_id": { + "type": "string" + }, + "workspace_key": { + "type": "string" + } + } } } }, @@ -2385,6 +5152,10 @@ "name": "Health", "description": "Health check and status endpoints" }, + { + "name": "Status", + "description": "Canonical status sections (TUI / VS Code parity)" + }, { "name": "Issue Types", "description": "Issue type CRUD operations" @@ -2397,10 +5168,18 @@ "name": "Collections", "description": "Issue type collection management" }, + { + "name": "Tickets", + "description": "Ticket CRUD and status management" + }, { "name": "Launch", "description": "Ticket launch operations" }, + { + "name": "Workflow", + "description": "Export tickets to Claude dynamic workflows" + }, { "name": "Skills", "description": "Skill discovery across LLM tools" @@ -2416,6 +5195,26 @@ { "name": "MCP", "description": "Model Context Protocol integration" + }, + { + "name": "Queue", + "description": "Ticket queue board, status, and control" + }, + { + "name": "Agents", + "description": "Active agent tracking and review actions" + }, + { + "name": "Projects", + "description": "Project discovery and ticket assessment" + }, + { + "name": "Configuration", + "description": "Operator configuration read/write" + }, + { + "name": "Kanban", + "description": "Kanban provider issue types and onboarding" } ] } \ No newline at end of file diff --git a/docs/schemas/project_analysis.json b/docs/schemas/project_analysis.json index fbd2ca2..a983e69 100644 --- a/docs/schemas/project_analysis.json +++ b/docs/schemas/project_analysis.json @@ -915,5 +915,5 @@ } }, "$id": "https://gbqr.us/operator/project-analysis.schema.json", - "$comment": "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema" + "$comment": "AUTO-GENERATED FROM src/taxonomy/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema" } \ No newline at end of file diff --git a/docs/shortcuts/index.md b/docs/shortcuts/index.md index b0956df..e99d3d4 100644 --- a/docs/shortcuts/index.md +++ b/docs/shortcuts/index.md @@ -32,7 +32,7 @@ Operator uses vim-style keybindings for navigation and actions. This reference d | `S` | Sync kanban collections | Dashboard | | `Y/y` | Approve review (agents panel) | Dashboard | | `X/x` | Reject review (agents panel) | Dashboard | -| `W/w` | Toggle Backstage server | Dashboard | +| `W/w` | Open web UI in browser | Dashboard | | `V/v` | Show session preview | Dashboard | | `F` | Focus cmux window | Dashboard | | `C` | Create new ticket | Dashboard | @@ -92,7 +92,7 @@ These shortcuts are available in the main dashboard view. | `S` | Sync kanban collections | | `Y/y` | Approve review (agents panel) | | `X/x` | Reject review (agents panel) | -| `W/w` | Toggle Backstage server | +| `W/w` | Open web UI in browser | | `V/v` | Show session preview | | `F` | Focus cmux window | diff --git a/docs/superpowers/plans/2026-05-16-acp-agent.md b/docs/superpowers/plans/2026-05-16-acp-agent.md new file mode 100644 index 0000000..76fddd8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-acp-agent.md @@ -0,0 +1,977 @@ +# Operator as an ACP Agent — Editor-Hosted Sessions over Stdio + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. + +**Goal:** Make operator runnable as an Agent Client Protocol (ACP) agent so editors that speak ACP — Zed, JetBrains (via the ACP Agent Registry), Emacs (`agent-shell`), Kiro, OpenCode, marimo, and Eclipse — can launch operator as a subprocess and host kanban-aware sessions inside the IDE. The editor becomes the chat surface; operator owns the ticket lifecycle and routes work to the configured delegator. + +**Architecture:** ACP is JSON-RPC 2.0 over stdio, bidirectional (both sides may initiate requests), with a session-based model. Editor spawns operator via `operator acp`, sends `initialize`, then `session/new` with a working directory, then `session/prompt` with user text. Operator responds with streaming `session/update` notifications and a final prompt response. Operator's v1 strategy is **bridge mode**: each ACP session corresponds to one operator ticket, and `session/prompt` launches a delegator subprocess (Claude Code, Codex CLI, Gemini CLI — whatever is configured) whose output is translated into ACP `session/update` chunks. Operator does **not** try to be the LLM-driving agent itself; it's the orchestrator that owns "which ticket, which delegator, which project." Lifecycle, config, and status integration mirror the existing `RestApiServer` pattern (`src/rest/server.rs`). + +**Tech Stack:** Rust 1.88+, tokio (async stdio + subprocess), `agent-client-protocol` crate (official Rust SDK from `github.com/agentclientprotocol/agent-client-protocol`), serde_json, clap (CLI), ratatui (status integration), the existing delegator infrastructure under `src/agents/`. + +--- + +## Critical Structural Approach + +Four decisions lock the rest of the plan: + +1. **Depend on the official `agent-client-protocol` Rust crate.** Operator already hand-rolls MCP JSON-RPC types because MCP is simple and the surface is small. ACP is bidirectional and has dozens of method shapes — hand-rolling is a maintenance trap. The Zed-published crate provides the `Agent` trait and message types; operator implements the trait. Verify the latest crate version on crates.io before pinning. + +2. **One ACP session = one operator ticket.** When the editor calls `session/new`, operator either (a) parses the working directory and prompt to attach to an existing in-progress ticket, or (b) creates a new ticket from a system prompt. The `sessionId` returned to the editor is the ticket UUID. This makes the ACP session traceable in the kanban board and lets the same session be resumed via `session/load` after a restart. + +3. **`session/prompt` does not run an LLM in-process — it delegates.** Operator spawns its configured delegator (Claude Code, Codex CLI, Gemini CLI) via the existing `src/agents/launcher.rs` infrastructure. The delegator's stdout/stderr is translated, line by line, into ACP `session/update` notifications. This keeps operator's orchestration role honest: it does not compete with the agent runtimes; it composes them. + +4. **Mirror the `RestApiServer` lifecycle for the stdio listener.** Even though `operator acp` is typically spawned by an editor (so its lifetime is bound to one editor connection), operator's TUI may also launch ACP listeners for inspection/testing. The `AcpAgentServer` handle (Status enum, `Arc>`, oneshot shutdown, session-file in `.operator/acp-session.json`) follows the same shape as `src/rest/server.rs:75-218`. The status panel (`ConnectionsSection`) gets an "ACP" row alongside "Operator API" and "MCP". + +The first task verifies the crate name and version. Everything else hangs off task 1. + +--- + +## File Structure + +**Create:** +- `src/acp/mod.rs` — module root, public re-exports +- `src/acp/agent.rs` — `OperatorAcpAgent` struct implementing the crate's `Agent` trait +- `src/acp/session.rs` — `AcpSession` (sessionId ↔ ticket ↔ delegator subprocess) +- `src/acp/translator.rs` — converts delegator stdout lines into ACP `session/update` notifications +- `src/acp/server.rs` — `AcpAgentServer` lifecycle handle (mirrors `RestApiServer`) +- `src/acp/client_configs.rs` — config snippets for Zed, JetBrains, Emacs, Kiro +- `tests/acp_integration.rs` — spawn `operator acp`, send initialize + session/new, assert response shape + +**Modify:** +- `Cargo.toml` — add `agent-client-protocol` dependency +- `src/main.rs:24-35` (modules), `:131-266` (Commands), `:281-362` (match arm), bottom (`cmd_acp`) +- `src/config.rs` — add `AcpConfig` struct + field on `Config` +- `src/ui/status_panel.rs` — add ACP fields to `StatusSnapshot`, new `StatusAction` variants +- `src/ui/sections/connections_section.rs` — add an ACP row + +--- + +## Tasks + +### Task 1: Add the `agent-client-protocol` crate dependency + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Find the latest crate version** + +Run: `cargo search agent-client-protocol --limit 5` +Expected output (similar): `agent-client-protocol = "0.21.0" # Rust SDK for ACP` + +Record the latest version. If the search fails or returns no results, fall back to checking the GitHub releases page at `https://github.com/agentclientprotocol/agent-client-protocol/releases` and reading the `Cargo.toml` in the `rust/` subdirectory. + +- [ ] **Step 2: Add to `Cargo.toml`** + +Edit `Cargo.toml`. Under `[dependencies]`, add: + +```toml +agent-client-protocol = "0.21" # pin to the version from Step 1 +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: clean build (just downloads + compiles the new crate; no operator code uses it yet). + +- [ ] **Step 4: Skim the crate's `Agent` trait** + +Run: `cargo doc --open --package agent-client-protocol` +Read the `Agent` trait and its associated message types. The remaining tasks reference method names from this trait. If the trait has changed shape relative to this plan (rename, new required method), pause and update the plan before proceeding. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 2: Scaffold `src/acp/` and wire it into the crate + +**Files:** +- Create: `src/acp/mod.rs` +- Modify: `src/main.rs:24-35` (module list) + +- [ ] **Step 1: Create the module file** + +Create `src/acp/mod.rs`: + +```rust +//! Agent Client Protocol (ACP) integration for Operator. +//! +//! Operator runs as an ACP agent that editors (Zed, JetBrains, Emacs, +//! Kiro, etc.) launch as a stdio subprocess. Each ACP session maps to +//! one operator ticket. Prompts are delegated to the configured runtime +//! (Claude Code, Codex CLI, Gemini CLI), and the delegator's stream is +//! translated into ACP `session/update` notifications. +//! +//! See: https://agentclientprotocol.com/ + +pub mod agent; +pub mod client_configs; +pub mod server; +pub mod session; +pub mod translator; +``` + +- [ ] **Step 2: Register the module** + +In `src/main.rs` around line 27 (alongside `mod mcp;`), add: + +```rust +mod acp; +``` + +- [ ] **Step 3: Verify** + +Run: `cargo build` +Expected: FAIL with "unresolved module" for each of `agent`, `client_configs`, `server`, `session`, `translator` — files don't exist yet. Comment out the unresolved lines, leaving only `pub mod agent;` (the first one we'll fill in). Or proceed straight to Task 3. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 3: Implement `OperatorAcpAgent` skeleton — initialize + capabilities + +**Files:** +- Create: `src/acp/agent.rs` + +The exact trait method signatures depend on the crate version pinned in Task 1. Adjust if the crate's `Agent` trait differs from what's shown here. Consult `cargo doc --open --package agent-client-protocol`. + +- [ ] **Step 1: Write a failing test for the initialize response** + +Create `src/acp/agent.rs`: + +```rust +//! Operator's implementation of the ACP `Agent` trait. + +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::acp::session::SessionRegistry; +use crate::config::Config; + +/// The operator-side ACP agent. +/// +/// Holds operator state needed to handle ACP requests: config, the +/// per-session ticket registry, and a handle to the delegator launcher. +pub struct OperatorAcpAgent { + pub config: Config, + pub sessions: Arc>, +} + +impl OperatorAcpAgent { + pub fn new(config: Config) -> Self { + Self { + config, + sessions: Arc::new(Mutex::new(SessionRegistry::default())), + } + } +} + +// ---- ACP Agent trait implementation ---- +// +// The exact trait shape and method signatures come from the +// `agent-client-protocol` crate. Look at the trait definition (cargo doc) +// and implement each required method. The skeleton below shows the four +// methods we need for v1: +// +// - initialize: return capabilities +// - new_session: create a session + ticket, return sessionId +// - prompt: delegate to the configured runtime, stream updates +// - cancel: signal the in-flight delegator to stop +// +// Use the crate's request/response types verbatim — do not re-define them. + +#[async_trait::async_trait] +impl agent_client_protocol::Agent for OperatorAcpAgent { + async fn initialize( + &self, + _params: agent_client_protocol::InitializeParams, + ) -> Result { + Ok(agent_client_protocol::InitializeResponse { + protocol_version: agent_client_protocol::PROTOCOL_VERSION, + agent_capabilities: agent_client_protocol::AgentCapabilities { + load_session: false, // v1: no resume + prompt_capabilities: Default::default(), + }, + auth_methods: vec![], + }) + } + + async fn new_session( + &self, + params: agent_client_protocol::NewSessionParams, + ) -> Result { + let session_id = self.sessions.lock().await.create_session(&self.config, ¶ms).await + .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; + Ok(agent_client_protocol::NewSessionResponse { session_id }) + } + + async fn prompt( + &self, + _params: agent_client_protocol::PromptParams, + ) -> Result { + // Implemented in Task 5 — for now return a placeholder so the trait compiles. + Err(agent_client_protocol::Error::method_not_supported( + "session/prompt not yet implemented", + )) + } + + async fn cancel( + &self, + _params: agent_client_protocol::CancelParams, + ) -> Result<(), agent_client_protocol::Error> { + Err(agent_client_protocol::Error::method_not_supported( + "session/cancel not yet implemented", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_initialize_advertises_v1_capabilities() { + let agent = OperatorAcpAgent::new(Config::default()); + let resp = agent.initialize(agent_client_protocol::InitializeParams::default()).await.unwrap(); + // v1 does not implement session/load — explicitly assert that + assert!(!resp.agent_capabilities.load_session); + } +} +``` + +- [ ] **Step 2: Run the test (expect compilation issues)** + +Run: `cargo test acp::agent` +Expected: Likely FAIL due to type/method-name mismatches with whatever version of the crate is pinned. Read the compile errors, look at `cargo doc`, and adjust the types/field names to match the crate's actual API. **Do not invent type names**; mirror what the crate exports. + +- [ ] **Step 3: Verify the test passes** + +Run: `cargo test acp::agent` +Expected: PASS. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 4: Implement `SessionRegistry` — sessionId ↔ ticket mapping + +**Files:** +- Create: `src/acp/session.rs` + +- [ ] **Step 1: Write a failing test** + +```rust +//! ACP session registry. +//! +//! Maps ACP session IDs (UUIDs) to operator tickets and the spawned +//! delegator subprocess for the in-flight prompt. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::process::Child; +use tokio::sync::Mutex; + +use crate::config::Config; + +#[derive(Default)] +pub struct SessionRegistry { + sessions: HashMap, +} + +pub struct AcpSession { + pub session_id: String, + pub ticket_id: String, + pub working_directory: PathBuf, + pub delegator: Option>>, +} + +impl SessionRegistry { + /// Create a new session, allocating a ticket. + /// + /// The ticket is created in `.tickets/in-progress/` immediately + /// (because the editor is actively using it) using the existing + /// `services::ticket_manager`. + pub async fn create_session( + &mut self, + config: &Config, + params: &agent_client_protocol::NewSessionParams, + ) -> Result { + let session_id = uuid::Uuid::new_v4().to_string(); + let ticket_id = crate::services::ticket_manager::create_in_progress( + config, + ¶ms.cwd, + &format!("ACP session from {}", params.cwd.display()), + ).await?; + let session = AcpSession { + session_id: session_id.clone(), + ticket_id, + working_directory: params.cwd.clone(), + delegator: None, + }; + self.sessions.insert(session_id.clone(), session); + Ok(session_id) + } + + pub fn get(&self, session_id: &str) -> Option<&AcpSession> { + self.sessions.get(session_id) + } + + pub fn get_mut(&mut self, session_id: &str) -> Option<&mut AcpSession> { + self.sessions.get_mut(session_id) + } + + pub fn remove(&mut self, session_id: &str) -> Option { + self.sessions.remove(session_id) + } + + pub fn len(&self) -> usize { + self.sessions.len() + } + + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_session_registry_create_and_lookup() { + let mut reg = SessionRegistry::default(); + let temp = tempfile::TempDir::new().unwrap(); + let config = { + let mut c = Config::default(); + c.paths.tickets_dir = temp.path().to_string_lossy().to_string(); + c + }; + let params = agent_client_protocol::NewSessionParams { + cwd: temp.path().to_path_buf(), + mcp_servers: vec![], + }; + let session_id = reg.create_session(&config, ¶ms).await.unwrap(); + assert!(reg.get(&session_id).is_some()); + assert_eq!(reg.len(), 1); + } +} +``` + +- [ ] **Step 2: Add the supporting `ticket_manager::create_in_progress` function** + +Grep for the existing ticket creation surface (`rg "fn create_ticket|fn write_ticket" src/`). If `create_in_progress` doesn't exist, add it alongside the existing creation function. It should write `.tickets/in-progress/{id}.md` with minimal frontmatter and return the ticket ID. + +- [ ] **Step 3: Run the test** + +Run: `cargo test acp::session` +Expected: PASS. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 5: Implement `session/prompt` — bridge to delegator + stream updates + +**Files:** +- Modify: `src/acp/agent.rs` (replace the placeholder `prompt` impl) +- Create: `src/acp/translator.rs` + +This is the core of operator-as-ACP. When the editor calls `session/prompt`, operator (a) looks up the session, (b) spawns the configured delegator with the prompt as input, (c) reads delegator stdout line-by-line, (d) emits ACP `session/update` notifications for each chunk, (e) returns the final response when the delegator exits. + +- [ ] **Step 1: Implement the translator** + +Create `src/acp/translator.rs`: + +```rust +//! Translate delegator subprocess output into ACP session/update notifications. +//! +//! Different delegators (Claude Code, Codex CLI, Gemini CLI) have different +//! stdout formats. This module hosts per-delegator translators. v1 implements +//! the simplest case: treat each non-empty line as an `assistant_message_chunk`. + +use agent_client_protocol::SessionUpdate; + +pub fn line_to_update(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + // Future: parse JSON-formatted output from `claude --output-format stream-json` + // and emit structured tool-call / tool-result updates. For v1, plain text. + Some(SessionUpdate::AssistantMessageChunk { + content: agent_client_protocol::ContentBlock::Text { + text: format!("{}\n", trimmed), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_blank_line_ignored() { + assert!(line_to_update("").is_none()); + assert!(line_to_update(" ").is_none()); + } + + #[test] + fn test_text_line_becomes_chunk() { + let update = line_to_update("hello").unwrap(); + match update { + SessionUpdate::AssistantMessageChunk { content } => match content { + agent_client_protocol::ContentBlock::Text { text } => { + assert!(text.contains("hello")); + } + _ => panic!("expected text content"), + }, + _ => panic!("expected assistant message chunk"), + } + } +} +``` + +(Field names like `SessionUpdate::AssistantMessageChunk` and `ContentBlock::Text` come from the crate — adjust if the crate uses different names.) + +- [ ] **Step 2: Replace the placeholder `prompt` in `agent.rs`** + +Replace the placeholder `prompt` impl from Task 3 with: + +```rust + async fn prompt( + &self, + params: agent_client_protocol::PromptParams, + notifier: agent_client_protocol::Notifier, + ) -> Result { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let session_id = params.session_id.clone(); + let (cwd, ticket_id) = { + let sessions = self.sessions.lock().await; + let s = sessions.get(&session_id).ok_or_else(|| { + agent_client_protocol::Error::invalid_params(format!("Unknown session: {session_id}")) + })?; + (s.working_directory.clone(), s.ticket_id.clone()) + }; + + // Build the delegator command from operator's configured default + let delegator = crate::agents::launcher::resolve_default_delegator(&self.config) + .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; + let prompt_text = params.prompt.iter() + .filter_map(|block| match block { + agent_client_protocol::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + + let mut child = tokio::process::Command::new(&delegator.command) + .args(&delegator.args) + .current_dir(&cwd) + .env("OPERATOR_TICKET_ID", &ticket_id) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| agent_client_protocol::Error::internal(format!("spawn delegator: {e}")))?; + + // Pipe the prompt to the delegator's stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(prompt_text.as_bytes()).await; + let _ = stdin.write_all(b"\n").await; + } + + // Stream stdout → session/update notifications + if let Some(stdout) = child.stdout.take() { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(update) = crate::acp::translator::line_to_update(&line) { + notifier.session_update(&session_id, update).await.ok(); + } + } + } + + let status = child.wait().await + .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; + + Ok(agent_client_protocol::PromptResponse { + stop_reason: if status.success() { + agent_client_protocol::StopReason::EndTurn + } else { + agent_client_protocol::StopReason::Refusal + }, + }) + } +``` + +(`agents::launcher::resolve_default_delegator` may need to be added if not present — it returns a `Delegator` struct with `command` and `args` based on operator's `[delegators]` config.) + +- [ ] **Step 3: Add a smoke test using `cat` as the delegator** + +Append to `src/acp/agent.rs::tests`: + +```rust + #[tokio::test] + async fn test_prompt_uses_cat_as_delegator_smoke() { + // Verifies the pipe-through path end-to-end using /bin/cat as the + // fake delegator. Skipped on non-unix. + #[cfg(unix)] + { + // ... configure agent with a Delegator { command: "cat", args: [] } + // ... call agent.prompt with text "hello" + // ... assert at least one AssistantMessageChunk with "hello" + } + } +``` + +(Filled in by the engineer using whatever mocking surface the crate's `Notifier` provides — likely a `MockNotifier` collected into a `Vec`.) + +- [ ] **Step 4: Run** + +Run: `cargo test acp::` +Expected: PASS. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 6: `operator acp` CLI subcommand + +**Files:** +- Create: `src/acp/server.rs` +- Modify: `src/main.rs:131-266` (Commands), `:281-362` (match), bottom (`cmd_acp`) + +- [ ] **Step 1: Implement the stdio entrypoint in `src/acp/server.rs`** + +```rust +//! ACP server lifecycle — runs the stdio listener. +//! +//! Mirrors the shape of `src/rest/server.rs:RestApiServer` so the TUI can +//! query status / start / stop. + +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::config::Config; + +#[derive(Debug, Clone, PartialEq)] +pub enum AcpStatus { + Stopped, + Starting, + Running { active_sessions: usize }, + Stopping, + Error(String), +} + +pub struct AcpAgentServer { + config: Config, + status: Arc>, + shutdown_tx: Arc>>>, + task_handle: Arc>>>, +} + +impl AcpAgentServer { + pub fn new(config: Config) -> Self { + Self { + config, + status: Arc::new(Mutex::new(AcpStatus::Stopped)), + shutdown_tx: Arc::new(Mutex::new(None)), + task_handle: Arc::new(Mutex::new(None)), + } + } + + pub fn status(&self) -> AcpStatus { + self.status.lock().unwrap().clone() + } + + pub fn is_running(&self) -> bool { + matches!(self.status(), AcpStatus::Running { .. }) + } +} + +/// Run the ACP stdio listener using the given reader/writer. +/// +/// Production callers pass `tokio::io::stdin()` / `tokio::io::stdout()`. +pub async fn run(config: Config, reader: R, writer: W) -> anyhow::Result<()> +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let agent = crate::acp::agent::OperatorAcpAgent::new(config); + // Use the crate's stdio adapter to wire the Agent impl onto a stdio + // transport. Exact function name from `cargo doc --open --package agent-client-protocol`. + agent_client_protocol::stdio::serve(agent, reader, writer).await?; + Ok(()) +} +``` + +- [ ] **Step 2: Add the CLI subcommand** + +In `src/main.rs`, add an `Acp` variant after `Mcp`: + +```rust + /// Run as an ACP agent over stdio (for use by Zed, JetBrains, Emacs, Kiro, etc.). + Acp, +``` + +Add the match arm: + +```rust + Some(Commands::Acp) => { + cmd_acp(&config).await?; + } +``` + +Add `cmd_acp` at the bottom of main.rs: + +```rust +async fn cmd_acp(config: &Config) -> Result<()> { + tracing::info!("Starting ACP stdio agent"); + crate::acp::server::run(config.clone(), tokio::io::stdin(), tokio::io::stdout()).await?; + tracing::info!("ACP agent stopped (stdin closed)"); + Ok(()) +} +``` + +- [ ] **Step 3: Verify the binary runs** + +Run: `cargo build --release` +Run: +``` +printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' | ./target/release/operator acp +``` +Expected: One line of JSON containing the operator agent's `initialize` response. (Exact shape determined by the crate.) + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 7: Add `[acp]` config section + +**Files:** +- Modify: `src/config.rs` + +- [ ] **Step 1: Add `AcpConfig`** + +```rust +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AcpConfig { + /// Whether to advertise the ACP stdio entrypoint in the status panel. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Default delegator to use when an ACP session/prompt arrives. + /// If None, falls back to `delegators[0]`. + #[serde(default)] + pub default_delegator: Option, + /// Maximum number of concurrent ACP sessions. Defaults to 4. + #[serde(default = "default_max_sessions")] + pub max_concurrent_sessions: usize, +} + +impl Default for AcpConfig { + fn default() -> Self { + Self { + stdio_advertised: true, + default_delegator: None, + max_concurrent_sessions: 4, + } + } +} + +fn default_max_sessions() -> usize { 4 } +``` + +- [ ] **Step 2: Add the field to `Config`** + +```rust + #[serde(default)] + pub acp: AcpConfig, +``` + +Update `Config::default()`. + +- [ ] **Step 3: Regenerate config docs** + +Run: `cargo run -- docs --only config` +Verify the generated docs describe `[acp]`. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 8: ACP editor config snippet generator + +**Files:** +- Create: `src/acp/client_configs.rs` + +Editors integrate ACP agents by registering them in editor-specific config files. Generate the right snippet for each. + +- [ ] **Step 1: Implement** + +```rust +//! Generates copy-paste ACP agent registrations for various editors. + +use serde_json::{json, Value}; +use std::path::PathBuf; + +fn exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +/// Zed: agent_servers entry in settings.json +pub fn zed_snippet() -> Value { + json!({ + "agent_servers": { + "operator": { + "command": exe().to_string_lossy(), + "args": ["acp"], + "env": {} + } + } + }) +} + +/// JetBrains: registered via the ACP Agent Registry; the operator entry +/// is a JSON object that JetBrains imports. +pub fn jetbrains_snippet() -> Value { + json!({ + "name": "operator", + "displayName": "Operator (Kanban Orchestrator)", + "command": exe().to_string_lossy(), + "args": ["acp"], + "icon": "https://operator.untra.io/icon.png" + }) +} + +/// Emacs (agent-shell): elisp form to add to init +pub fn emacs_snippet() -> String { + format!( + "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))", + exe().display() + ) +} + +/// Kiro CLI: ~/.kiro/agents.toml entry +pub fn kiro_snippet() -> String { + format!( + "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n", + exe().display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zed_snippet_shape() { + let snippet = zed_snippet(); + assert_eq!(snippet["agent_servers"]["operator"]["args"][0], "acp"); + } + + #[test] + fn test_emacs_snippet_is_valid_elisp() { + let snippet = emacs_snippet(); + assert!(snippet.starts_with("(add-to-list")); + assert!(snippet.contains("acp")); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test acp::client_configs` +Expected: PASS. + +- [ ] **Step 3: Stop for commit review** + +--- + +### Task 9: Integrate ACP into `StatusSnapshot` and `ConnectionsSection` + +**Files:** +- Modify: `src/ui/status_panel.rs` +- Modify: `src/ui/sections/connections_section.rs` + +- [ ] **Step 1: Add ACP fields to `StatusSnapshot`** + +```rust + /// Whether the `[acp]` stdio entrypoint is advertised. + pub acp_stdio_advertised: bool, + /// Active ACP sessions (only relevant if operator launched an ACP listener itself). + pub acp_active_sessions: usize, +``` + +- [ ] **Step 2: Add new `StatusAction` variants** + +```rust + /// Copy an ACP editor config snippet to the clipboard. + /// `editor` is one of: "zed", "jetbrains", "emacs", "kiro". + CopyAcpEditorConfig { editor: String }, + /// Open ACP setup docs in the browser. + OpenAcpDocs, +``` + +- [ ] **Step 3: Add an ACP row to `ConnectionsSection::children`** + +After the MCP row added by the MCP plan (Task 9), append: + +```rust + rows.push(TreeRow { + section_id: SectionId::Connections, + depth: 1, + label: "ACP".into(), + description: if snapshot.acp_stdio_advertised { + if snapshot.acp_active_sessions > 0 { + format!("stdio · {} sessions", snapshot.acp_active_sessions) + } else { + "stdio ready".into() + } + } else { + "Disabled".into() + }, + icon: if snapshot.acp_stdio_advertised { StatusIcon::Plug } else { StatusIcon::Cross }, + is_header: false, + actions: ActionSet { + primary: StatusAction::CopyAcpEditorConfig { editor: "zed".to_string() }, + back: StatusAction::None, + special: StatusAction::CopyAcpEditorConfig { editor: "jetbrains".to_string() }, + special_meta: Some(ActionMeta { title: "JBrn", tooltip: "Copy JetBrains ACP registry snippet" }), + refresh: StatusAction::OpenAcpDocs, + refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open ACP setup docs" }), + }, + health: SectionHealth::Gray, + }); +``` + +- [ ] **Step 4: Update `StatusSnapshot` construction site** + +Same pattern as the MCP plan's Task 9 Step 4: populate the new fields. `acp_active_sessions` defaults to `0` unless operator is also hosting an ACP listener itself (uncommon — the editor typically hosts). + +- [ ] **Step 5: Update test snapshots** + +Search test files for `StatusSnapshot {` (now including the MCP fields from the MCP plan) and add `acp_stdio_advertised: true, acp_active_sessions: 0`. + +- [ ] **Step 6: Add a test for the ACP row** + +```rust + #[test] + fn test_connections_acp_row_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let row = children.iter().find(|r| r.label == "ACP"); + assert!(row.is_some(), "ACP row should always be present"); + } +``` + +- [ ] **Step 7: Wire the action handlers** + +Find the `StatusAction` dispatch site (grep `StatusAction::StartApi =>`). Add: +- `CopyAcpEditorConfig { editor }` — generate via `crate::acp::client_configs`, write to clipboard +- `OpenAcpDocs` — open `https://operator.untra.io/acp/` via the existing `OpenUrl` helper + +- [ ] **Step 8: Verify** + +Run: `cargo fmt && cargo clippy -- -D warnings && cargo test` +Expected: green. + +- [ ] **Step 9: Stop for commit review** + +--- + +### Task 10: End-to-end integration test + +**Files:** +- Create: `tests/acp_integration.rs` + +- [ ] **Step 1: Write the test** + +```rust +//! Spawn `operator acp` and roundtrip an initialize request. + +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn test_operator_acp_initialize_roundtrip() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + // Initialize message — exact shape depends on the ACP crate version + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} +"#; + stdin.write_all(init).await.unwrap(); + stdin.flush().await.unwrap(); + + let line = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()) + .await.unwrap().unwrap().unwrap(); + let resp: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(resp["id"], 1); + assert!(resp["result"].is_object()); + + drop(stdin); + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test --test acp_integration -- --nocapture` +Expected: PASS. + +- [ ] **Step 3: Final verification** + +Run: +``` +cargo fmt +cargo clippy -- -D warnings +cargo test +``` +Expected: green. + +- [ ] **Step 4: Stop for user to commit** + +--- + +## Self-Review + +**Spec coverage:** +- ACP crate dependency (Task 1) — covered +- `Agent` trait impl with initialize / new_session / prompt / cancel (Tasks 3, 5) — covered (`session/cancel` is left as a stub in Task 3; v1.1 should implement it by signaling the delegator subprocess) +- Session ↔ ticket mapping (Task 4) — covered +- Delegator bridge + stream translation (Task 5) — covered +- CLI subcommand (Task 6) — covered +- Config section (Task 7) — covered +- Editor config snippets (Task 8) — covered +- Status integration (Task 9) — covered +- End-to-end test (Task 10) — covered + +**Open assumptions to verify before starting:** +1. Exact crate name, version, and trait shape of `agent-client-protocol`. Task 1 validates. +2. The `Notifier` injection on `Agent::prompt` — the trait method signature in Task 3 shows `_params` only, but Task 5 uses `notifier: Notifier`. Confirm the real signature; the crate likely passes the notifier via a `&self` field or an additional parameter. +3. The existence of `agents::launcher::resolve_default_delegator` — may need to be added. +4. `services::ticket_manager::create_in_progress` — may need to be added. + +**v1 limitations explicitly accepted:** +- No `session/load` — ACP sessions don't survive operator restart. (Easy to add later: the registry writes to `.operator/acp-sessions.json`.) +- No `session/cancel` — a v1.1 task. +- No structured tool-call translation. The delegator's raw text becomes `AssistantMessageChunk`. When using Claude Code with `--output-format stream-json`, parse and emit structured `ToolCall` updates instead. (Task 5's translator is the single chokepoint to extend.) +- No `fs/*` request forwarding. The delegator subprocess does its own filesystem access. This means file edits don't surface as approvable actions in the editor — an explicit v1 tradeoff. If a target editor needs approval routing, switch from "delegator owns FS" to "operator owns FS, asks editor for permission" in v2. +- Single-tenant: operator runs in the project directory the editor opens it in. Multi-project sessions need a v2 design decision (do separate editor windows share an operator process, or each get their own?). diff --git a/docs/superpowers/plans/2026-05-16-acp-zed-extension.md b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md new file mode 100644 index 0000000..d8b570a --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md @@ -0,0 +1,320 @@ +# Plan: ACP Integration for the Operator Zed Extension + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. + +## Context + +A sibling plan, `docs/superpowers/plans/2026-05-16-acp-agent.md`, wires the **operator binary** itself as an ACP agent (`operator acp` over stdio). This follow-up plan picks up where that one ends: integrate ACP into the **`zed-extension/`** package so Zed users can launch Operator from the agent panel — not just via slash commands. + +Today the Zed extension (`zed-extension/src/lib.rs`, `extension.toml`) is a WASM-sandboxed slash-command bridge: 11 `/op-*` commands shell out to `curl` against the local REST API (`http://localhost:7008`). It does not register an ACP agent. The Zed agent panel surface is unused. + +This plan adds ACP-agent registration to the existing extension so: +- Zed's agent panel shows **Operator** as a selectable agent alongside Claude / Codex / Gemini CLI +- Selecting Operator and opening a new thread spawns `operator acp` in the project root, wired to Zed via JSON-RPC stdio +- The existing `/op-*` slash commands stay — they cover different needs (status queries, queue inspection) and complement the agent thread + +Workflow this enables: a developer in a Zed window for project X opens the agent panel, picks Operator, and chats. Operator (per the upstream ACP plan) creates a ticket from that chat, picks the next queued ticket if one matches, and delegates to Claude Code / Codex / Gemini under the hood — streaming the delegator's output back as `session/update` notifications visible inside Zed. + +## Hard Dependency + +This plan **assumes the operator-side ACP plan is complete and merged.** Specifically: +- `operator acp` subcommand exists and serves a working `Agent` impl over stdio +- `initialize`, `session/new`, and `session/prompt` roundtrip cleanly +- `tests/acp_integration.rs` is green + +If `operator acp` doesn't exist yet, **execute the upstream plan first.** This plan adds Zed-side packaging on top. + +## How Zed Discovers ACP Agents (Key Facts) + +From `https://zed.dev/docs/extensions/agent-servers` and the user-config docs: + +1. **Manifest registration:** A Zed extension declares ACP agents via `[agent_servers.]` blocks in `extension.toml`. Each block has `name`, optional `icon`, optional `env`, plus per-platform `targets.-` entries with `archive` (download URL), `cmd`, `args`, and recommended `sha256`. +2. **User override:** Users can override extension-provided agents (or add custom ones) under `agent_servers` in `settings.json`. The custom form is `{"type": "custom", "command": "...", "args": [...], "env": {...}}`. The registry form is `{"type": "registry", ...}` for curated entries. +3. **Lifecycle:** Zed spawns the configured command as a subprocess with `cwd` = the project root and pipes JSON-RPC over its stdio. No WASM API call is required from the extension code (`src/lib.rs`). +4. **Forwarded context:** Zed passes the project root as `cwd` in `session/new`, plus MCP server configurations, and forwards model/mode selection if the agent advertises support. + +Consequence: the **majority of this plan is `extension.toml` + docs + a release pipeline** — `src/lib.rs` does not need ACP code, because ACP runs in the operator binary, not in the WASM sandbox. + +## Critical Files + +**Modify:** +- `zed-extension/extension.toml` — add `[agent_servers.operator]` block with platform targets +- `zed-extension/README.md` — document the agent panel flow alongside slash commands +- `zed-extension/TODO.md` — mark ACP agent panel as ✅ implemented; audit "not possible" entries against what ACP unblocks +- `bump-version.sh` — bump the extension's version when shipping +- `.github/workflows/*.yml` (or equivalent) — publish per-platform operator archives whose URLs are referenced from `extension.toml` + +**Create:** +- `zed-extension/docs/acp-setup.md` — short walkthrough: install the extension, configure `agent_servers` in `settings.json` for dev, or use the bundled archive in release mode +- `zed-extension/tests/acp_smoke.sh` (or a CI step) — end-to-end smoke that builds operator, starts it under `operator acp`, sends `initialize`, asserts the JSON response +- `src/integrations/inventory.rs` (operator crate) — single source-of-truth list of operator capabilities exposed across surfaces +- `tests/surface_parity.rs` (operator crate) — enforces that every capability has both a slash-command and an ACP-tool entry point + +**Do NOT modify:** +- `zed-extension/src/lib.rs` — slash commands stay as-is. ACP runs out-of-process in the operator binary, not in the extension WASM. + +## Tasks + +### Task 1: Confirm operator-side ACP is functional + +- [ ] **Step 1:** Run `cargo run -- acp < /tmp/init.json` from operator root with a hand-rolled JSON-RPC `initialize` request. Assert it produces a valid `InitializeResponse` containing `agentCapabilities` with `loadSession: false` (per upstream plan v1). +- [ ] **Step 2:** Run `cargo test --test acp_integration` and confirm green. +- [ ] **Step 3:** If either fails, **stop** — the upstream plan is the blocker; finish that first. + +--- + +### Task 2: Add `[agent_servers.operator]` to `extension.toml` + +**Files:** +- Modify: `zed-extension/extension.toml` + +- [ ] **Step 1:** Add the agent_servers block after `[slash_commands]`: + +```toml +[agent_servers.operator] +name = "Operator" +icon = "https://operator.untra.io/icon.png" + +[agent_servers.operator.targets.darwin-aarch64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-aarch64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" + +[agent_servers.operator.targets.darwin-x86_64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-x86_64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" + +[agent_servers.operator.targets.linux-x86_64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-x86_64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" + +[agent_servers.operator.targets.linux-aarch64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-aarch64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" +``` + +- [ ] **Step 2:** Pin `{VERSION}` to the operator version that first contains `operator acp`. Bake `{SHA256}` per target at release time via `bump-version.sh` (or accept manual updates in Task 3). +- [ ] **Step 3:** Confirm `extension.toml` parses by building the extension: + ```bash + cd zed-extension && cargo build --release --target wasm32-wasip1 + ``` +- [ ] **Step 4:** Stop for commit review. + +--- + +### Task 3: Update the release pipeline to ship operator archives + +The `archive` URLs in Task 2 must resolve to real artifacts. Inventory `.github/workflows/` and `bump-version.sh` first to see what exists; add the missing pieces. + +**Files:** +- Modify: `.github/workflows/*.yml` (release workflow) +- Modify: `bump-version.sh` + +- [ ] **Step 1:** Add a CI step that, on a tagged release, produces `operator-{os}-{arch}.tar.gz` for the four target tuples in Task 2. Each archive contains the `operator` binary at the archive root (so `cmd = "./operator"` resolves). +- [ ] **Step 2:** Add a CI step that computes each archive's `sha256` and rewrites `zed-extension/extension.toml` with the real `{SHA256}` and `{VERSION}` values before publishing the extension. +- [ ] **Step 3:** Verify by tagging a pre-release and downloading one archive locally: + ```bash + tar -tzf operator-darwin-aarch64.tar.gz | head + ``` + Expected: `operator` appears at the top level. +- [ ] **Step 4:** Stop for commit review. + +--- + +### Task 4: Document the dev-mode override + +Most operator developers will not consume the archive — they'll point Zed at their local debug build. Document this clearly so the extension is usable before the release pipeline is finished. + +**Files:** +- Create: `zed-extension/docs/acp-setup.md` + +- [ ] **Step 1:** Write the setup doc, including: + + ````markdown + # Operator ACP Setup + + ## Dev mode (local binary) + + Add to `~/.config/zed/settings.json` (or per-project `.zed/settings.json`): + + ```jsonc + { + "agent_servers": { + "operator": { + "type": "custom", + "command": "/Users/you/Documents/gbqr-us/operator/target/debug/operator", + "args": ["acp"], + "env": { + "RUST_LOG": "operator=debug" + } + } + } + } + ``` + + This override takes precedence over the extension-provided `[agent_servers.operator]` block, so you can run an unreleased build of operator without rebuilding the extension. + + ## Verify it works + + 1. Open Zed's agent panel + 2. Pick **Operator** from the agent selector + 3. Open a new thread + 4. Type `hello` — you should see streamed output + + ## Release mode + + Install the extension from the Zed extension registry. Zed fetches the matching `operator-{os}-{arch}.tar.gz` archive automatically; no `settings.json` changes needed. + + ## Known issues + + (Populated as Task 7 surfaces them.) + ```` + +- [ ] **Step 2:** Cross-link from `zed-extension/README.md` and the operator-side `docs/cli/index.md` ACP section. +- [ ] **Step 3:** Stop for commit review. + +--- + +### Task 5: Rewrite README + TODO to reflect dual-surface + +`zed-extension/README.md` and `zed-extension/TODO.md` currently document only the slash-command surface and list many features as "Not Possible in Zed." ACP unblocks several. Update them honestly. + +**Files:** +- Modify: `zed-extension/README.md` +- Modify: `zed-extension/TODO.md` + +- [ ] **Step 1:** In `README.md`, add a top-level "Two ways to use the extension" section: + 1. **Slash commands** — existing, REST-backed status queries surfaced in the AI assistant + 2. **Agent panel** — new, ACP-backed full sessions inside Zed +- [ ] **Step 2:** State explicitly that the two surfaces are intentionally parallel — they cover the same operator concepts (queue, tickets, agents, kanban) but from different entry points. Task 6 enforces this with tests. +- [ ] **Step 3:** In `TODO.md`, audit each "Not Possible in Zed" row honestly: + - Sidebar Views → still N/A (ACP doesn't help here) + - Webhook Server → still N/A + - **Terminal Management** → N/A in extension, but ACP sessions provide an in-IDE chat surface + - **Status Bar** → still N/A + - **File System Watching** → still N/A in WASM, but the ACP path lets the agent see filesystem state via `fs/read_text_file` requests routed to Zed +- [ ] **Step 4:** Be specific about what ACP does and doesn't add. Don't oversell. +- [ ] **Step 5:** Stop for commit review. + +--- + +### Task 6: Structural-parity tests between slash-command and ACP surfaces + +The two surfaces (slash commands, ACP threads) must stay in structural sync: adding a new operator capability shouldn't expose it on only one side. Drive both surfaces from a single shared inventory of operator capabilities and let CI enforce the contract. + +**Files:** +- Create: `src/integrations/inventory.rs` (operator crate) +- Create: `tests/surface_parity.rs` (operator crate) +- Create: `zed-extension/tests/acp_smoke.sh` + +- [ ] **Step 1: Add `src/integrations/inventory.rs`** (operator-side, not WASM) enumerating user-facing operator capabilities. One entry per: + ```rust + pub struct Capability { + pub id: &'static str, // e.g. "queue.list" + pub description: &'static str, + pub rest_endpoint: Option<&'static str>, // path matched against OpenAPI + pub slash_command_id: Option<&'static str>, // e.g. "op-queue" + pub acp_tool_id: Option<&'static str>, // e.g. "operator__queue_list" + } + + pub const INVENTORY: &[Capability] = &[ /* ... */ ]; + ``` + Use the existing OpenAPI generation output as the source-of-truth for `rest_endpoint` values. + +- [ ] **Step 2: Add `tests/surface_parity.rs`** asserting: + 1. Every `slash_command_id` in the inventory corresponds to a registered slash command in `zed-extension/extension.toml` (parse the TOML, check the `[slash_commands]` table). + 2. Every `acp_tool_id` is exposed by the operator ACP agent. The exact mechanism depends on what the upstream ACP plan ships — if v1 only delegates to Claude/Codex/Gemini, the ACP surface may expose operator-specific tools through the co-shipped MCP server (covered by the MCP plan at `2026-05-16-mcp-stdio-and-tickets.md`). + 3. Every entry has BOTH a `slash_command_id` AND an `acp_tool_id`, OR an explicit allow-list reason in a separate `tests/fixtures/surface_exceptions.toml`. The default is parity; deviations require an explicit rationale. + +- [ ] **Step 3: Add `zed-extension/tests/acp_smoke.sh`** for runtime smoke (separate from parity): + ```bash + #!/usr/bin/env bash + set -euo pipefail + cd "$(dirname "$0")/../.." + cargo build --bin operator + printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' \ + | ./target/debug/operator acp \ + | head -1 \ + | jq -e '.result.agentCapabilities' + ``` + +- [ ] **Step 4: Wire both into CI.** Parity runs as `cargo test --test surface_parity`. Smoke runs as a shell job. Any failure blocks releases. + +- [ ] **Step 5: Document the contract in `zed-extension/README.md`:** "Adding a new operator capability requires registering it in `src/integrations/inventory.rs`. CI will fail if the new entry lacks either a slash command or an ACP tool (without an explicit exception entry)." + +- [ ] **Step 6:** Run full validation: + ```bash + cargo fmt && cargo clippy -- -D warnings && cargo test + bash zed-extension/tests/acp_smoke.sh + ``` + Expected: green. + +- [ ] **Step 7:** Stop for commit review. + +--- + +### Task 7: User-facing verification in Zed + +Before declaring the integration shipped, manually verify in the real editor — CI cannot prove this. + +- [ ] **Step 1:** Install the extension as dev: + ```bash + cd zed-extension && cargo build --release --target wasm32-wasip1 + mkdir -p ~/.local/share/zed/extensions/installed/operator-dev/ + cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/ + cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm + ``` +- [ ] **Step 2:** Apply the `settings.json` override from Task 4. +- [ ] **Step 3:** Open a Zed project that has `.tickets/` (the operator repo itself works). +- [ ] **Step 4:** Open agent panel → confirm **Operator** appears in the agent selector. +- [ ] **Step 5:** Open a thread → confirm the prompt arrives and streams a response from the configured delegator. +- [ ] **Step 6:** Cancel a thread mid-stream → confirm the delegator process exits (per upstream plan's `session/cancel` task — may be a v1.1 follow-up). +- [ ] **Step 7:** Document any rough edges in `zed-extension/docs/acp-setup.md` under "Known issues." +- [ ] **Step 8:** Stop for user to commit. + +--- + +## Verification + +End-to-end acceptance passes when: +1. `cargo build --release --target wasm32-wasip1` from `zed-extension/` produces a valid WASM artifact. +2. `bash zed-extension/tests/acp_smoke.sh` exits 0. +3. `cargo test --test surface_parity` exits 0. +4. In Zed with the dev override: opening the agent panel → Operator → new thread → typing `hello` → streamed text returns. (Human verification — primary gate.) +5. The four release archive URLs in `extension.toml` resolve to real artifacts whose SHA256 matches. + +## Self-Review + +**Spec coverage:** +- Operator-side ACP confirmation (Task 1) — covered +- Extension manifest (Task 2) — covered +- Release pipeline (Task 3) — covered +- Dev-mode override docs (Task 4) — covered +- README + TODO updates (Task 5) — covered +- Structural-parity tests + smoke (Task 6) — covered +- Manual Zed verification (Task 7) — covered + +**Open assumptions:** +- The operator-side ACP plan ships first. This plan is gated on `operator acp` working — without it there's nothing for Zed to connect to. +- Operator's release pipeline can produce per-target archives. If today's pipeline only produces a single platform, Task 3 expands. +- The Zed `[agent_servers.]` manifest schema is stable. Zed documents it publicly, but the schema is newer than the slash-command API and may shift. + +**Explicit non-goals (v1):** +- No JetBrains, Emacs, Kiro, etc. integration. The operator-side plan generates config snippets (Task 8 there) covering those — they don't need a per-editor extension because they read user config directly. +- No deprecation of slash commands. They serve different workflows. +- No sidebar / status bar / file watcher work — ACP doesn't unblock these in Zed's current extension API. +- No per-ticket "Open in Operator agent panel" deep-link from slash commands. Conceivable but out of scope here. + +**Resolved decisions:** +1. Release archives are in scope for v1 (Task 3 ships per-platform tarballs + SHA256 + `extension.toml` pinning). +2. Slash commands stay as a parallel surface. Task 6 enforces structural parity between the two surfaces with tests so they cannot drift silently. +3. Plan file lives at `docs/superpowers/plans/2026-05-16-acp-zed-extension.md` (sibling of the operator-side ACP plan). diff --git a/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md new file mode 100644 index 0000000..2004b80 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md @@ -0,0 +1,1580 @@ +# Operator MCP — Stdio Transport, Ticket Tools, and Status Integration + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. + +**Goal:** Add stdio transport for operator's MCP server, expand the tool surface to cover ticket queue read/write operations, advertise the stdio entrypoint through the existing `McpDescriptorResponse` so the vscode-extension can pick it up, and surface MCP lifecycle through operator's existing `StatusSection` pattern so users can toggle it and copy client configs from the dashboard. + +**Architecture:** The HTTP/SSE MCP transport (`src/mcp/transport.rs`) already implements the protocol bound to `ApiState` and exposes seven read-only tools. `src/mcp/descriptor.rs` already publishes a discovery endpoint that the vscode-extension consumes. The structural move is to (1) extract the JSON-RPC dispatch core into a transport-agnostic module, (2) add a stdio transport that reads line-delimited JSON-RPC from stdin and writes to stdout, (3) add an `operator mcp` CLI subcommand as the entrypoint MCP clients launch, (4) expand the tool surface with ticket-queue operations that call into the existing `src/queue/Queue` (sync API, wrapped via `tokio::task::spawn_blocking`) and `TicketCreator`, (5) extend the existing descriptor with an optional `stdio: StdioCommand` field so IDE extensions can switch transports without a new endpoint, and (6) add a row to `ConnectionsSection` mirroring the `Operator API` lifecycle pattern. Stdio is the dominant MCP transport across Claude Code, Cursor, VS Code, Zed, and JetBrains; the existing HTTP transport stays as-is for network use. + +**Tech Stack:** Rust 1.88+, tokio, serde_json, axum (existing for HTTP), clap (CLI), ratatui (status integration), ts-rs + schemars (for config + descriptor binding regeneration). No new top-level dependencies required — `tokio::io::{AsyncBufReadExt, AsyncWriteExt}` is sufficient for the stdio loop, and `EditFile` action + existing file I/O cover the client-config snippet delivery (no clipboard dependency). + +--- + +## Pre-Flight (verify before starting) + +Run each of these from the operator project root and confirm the output matches the assumption. If any differ, fix the relevant task before implementing. + +1. `rg -n "pub fn claim_ticket|pub fn complete_ticket|pub fn list_queue" src/queue/` + → should find sync methods in `src/queue/mod.rs` around lines 134-158. +2. `rg -n "pub struct McpDescriptorResponse" src/mcp/` + → should find `src/mcp/descriptor.rs:14`. Confirms the descriptor already exists (do not re-create it). +3. `rg -n "mcp_sessions" src/rest/state.rs` + → should find the field on `ApiState` typed `Arc>>` where `Mutex` is `tokio::sync::Mutex` (line 7 imports). +4. `head -50 vscode-extension/src/mcp-connect.ts` + → confirm the consumer reads `server_name`, `transport_url` from the descriptor. The descriptor extension in Task 6.5 must stay additive (Option field with `skip_serializing_if`). +5. `cargo build --release && ls target/release/operator` + → confirms the binary path that `client_configs::current_exe()` will return. +6. `rg -n "pub struct TicketCreator|pub fn create_ticket_with_values" src/queue/creator.rs` + → should find the existing creator at lines 16-68. Confirms the headless variant in Task 5.5 is additive. + +--- + +## Critical Structural Approach + +Three decisions lock the rest of the plan: + +1. **Transport-agnostic handler.** The function `handle_jsonrpc(&JsonRpcRequest, &ApiState) -> JsonRpcResponse` in `src/mcp/transport.rs` already has the right shape but lives in a file named after HTTP. Extract it to `src/mcp/handler.rs` unchanged. Both transports import it. + +2. **`ApiState` is the shared substrate; `Queue` is the ticket-write surface.** Existing tools call `routes::*` handlers, which take `State`. New ticket-queue tools should construct `crate::queue::Queue::new(&state.config)` and call its **sync** methods (`list_queue`, `claim_ticket(&Ticket)`, `complete_ticket(&Ticket)`, `return_to_queue(&Ticket)`) inside `tokio::task::spawn_blocking`. Creation uses `crate::queue::creator::TicketCreator` via a new headless variant (Task 5.5). Do **not** introduce an in-process HTTP roundtrip. + +3. **No new server lifecycle for stdio.** Stdio MCP is spawned by the client (Claude Code, Cursor, VS Code, …) as a subprocess — it does not run inside the operator TUI. The HTTP-MCP toggle (`config.mcp.http_enabled`) is implemented by conditionally including the MCP routes in `build_router`; flipping it requires an API restart. There is **no** `McpStdioServer` struct, no shutdown channels for stdio. Status display in `ConnectionsSection` reflects (a) whether HTTP MCP routes are mounted on the current API server and (b) whether the stdio entrypoint is advertised in the descriptor. + +--- + +## File Structure + +**Create:** +- `src/mcp/handler.rs` — transport-agnostic `handle_jsonrpc` + JSON-RPC types +- `src/mcp/stdio.rs` — line-delimited stdio JSON-RPC loop +- `src/mcp/tickets.rs` — ticket-queue tools (separated from REST-wrapping `tools.rs`) +- `src/mcp/resources.rs` — MCP resources (tickets exposed as URIs) +- `src/mcp/client_configs.rs` — generates copy-paste config snippets for Claude Code, Claude Desktop, Cursor, VS Code, Zed +- `tests/mcp_stdio_integration.rs` — end-to-end test: spawn `operator mcp`, send init + tools/list over a pipe + +**Modify:** +- `src/mcp/mod.rs` — add `handler`, `stdio`, `tickets`, `resources`, `client_configs` modules +- `src/mcp/transport.rs` — import `handle_jsonrpc` from `handler.rs`; delete the local copy +- `src/mcp/tools.rs` — merge ticket tools from `tickets.rs` into `all_tool_definitions` and `execute_tool`; update tool-count assertion +- `src/mcp/descriptor.rs` — extend existing `McpDescriptorResponse` with `stdio: Option`; inject `State` into the handler so it can read `config.mcp.stdio_advertised` +- `src/rest/mod.rs` — gate MCP route mounting on `config.mcp.http_enabled` +- `src/queue/creator.rs` — add `create_ticket_headless` (no editor launch) +- `src/main.rs` — add `Commands::Mcp` variant and `cmd_mcp` async fn +- `src/config.rs` — add `McpConfig` struct (fields: `http_enabled`, `stdio_advertised`, `expose_ticket_write_tools`) with `JsonSchema + TS` derives, and field on `Config` +- `src/ui/status_panel.rs` — add `mcp_http_status: McpHttpStatus` + `mcp_stdio_advertised: bool` + `mcp_active_sessions: usize` to `StatusSnapshot`; add new `StatusAction` variants: `ToggleMcpHttp`, `WriteAndOpenMcpClientConfig { client: String }`, `OpenMcpDocs` +- `src/ui/sections/connections_section.rs` — add an "MCP" row after the "Operator API" row +- `src/ui/dashboard.rs` — populate the new `StatusSnapshot` fields at the construction site +- `src/app/status_actions.rs` — handle the three new `StatusAction` variants + +Session files live under `/operator/` (see `src/rest/server.rs:27`). Generated client-config snippets go to `/operator/mcp/.json`. + +--- + +## Tasks + +### Task 1: Extract the JSON-RPC handler to a transport-agnostic module + +**Files:** +- Create: `src/mcp/handler.rs` +- Modify: `src/mcp/transport.rs` +- Modify: `src/mcp/mod.rs:7-9` + +- [ ] **Step 1: Move types and dispatch to `handler.rs`** + +Create `src/mcp/handler.rs` with the contents below. These are the existing types from `transport.rs:24-51` plus the existing `handle_jsonrpc` fn from `transport.rs:136-233`, with `pub` added to `handle_jsonrpc` and `JsonRpcResponse`/`JsonRpcError` so other transports can use them. + +```rust +//! Transport-agnostic JSON-RPC handler for MCP. +//! +//! Both the HTTP/SSE transport (`transport.rs`) and the stdio transport +//! (`stdio.rs`) dispatch through `handle_jsonrpc`. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::mcp::tools; +use crate::rest::state::ApiState; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + #[allow(dead_code)] + pub jsonrpc: String, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, +} + +pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { + let id = request.id.clone().unwrap_or(Value::Null); + match request.method.as_str() { + "initialize" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {}, "resources": { "subscribe": false, "listChanged": false } }, + "serverInfo": { "name": "operator", "version": env!("CARGO_PKG_VERSION") } + })), + error: None, + }, + "notifications/initialized" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({})), + error: None, + }, + "tools/list" => { + let tool_defs = tools::all_tool_definitions(); + let tools_json: Vec = tool_defs.into_iter().map(|t| json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema, + })).collect(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "tools": tools_json })), + error: None, + } + } + "tools/call" => { + let tool_name = request.params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let arguments = request.params.get("arguments").cloned().unwrap_or_else(|| json!({})); + match tools::execute_tool(tool_name, arguments, state).await { + Ok(result) => { + let text = serde_json::to_string_pretty(&result).unwrap_or_default(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "content": [{ "type": "text", "text": text }] })), + error: None, + } + } + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { code: -32000, message: e }), + }, + } + } + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + }), + }, + } +} +``` + +Note: the `initialize` capabilities object already advertises `resources` here so Task 6 doesn't need to re-edit it. + +- [ ] **Step 2: Update `transport.rs` to import from `handler.rs`** + +Replace lines 24-51 and the entire `handle_jsonrpc` function (lines 136-233) in `src/mcp/transport.rs` with: + +```rust +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +``` + +at the top, and update the `message_handler` call site (line 125) to use the imported `handle_jsonrpc`. The local `JsonRpcResponse` and `JsonRpcError` types are no longer needed in `transport.rs` — delete them. + +- [ ] **Step 3: Wire the new module into `src/mcp/mod.rs`** + +Edit `src/mcp/mod.rs` to add the new modules (some don't exist yet — comment them out until the corresponding task creates the file, or add them all and let `cargo check` fail until the files land): + +```rust +//! Model Context Protocol (MCP) integration for Operator. + +pub mod client_configs; +pub mod descriptor; +pub mod handler; +pub mod resources; +pub mod stdio; +pub mod tickets; +pub mod tools; +pub mod transport; +``` + +- [ ] **Step 4: Move the existing handler tests to `handler.rs`** + +The six tests in `src/mcp/transport.rs:236-371` (`test_handle_initialize`, `test_handle_tools_list`, `test_handle_tools_call_health`, `test_handle_tools_call_unknown`, `test_handle_unknown_method`, `test_handle_notifications_initialized`) all test `handle_jsonrpc` directly. Move them verbatim to a `#[cfg(test)] mod tests { ... }` block in `src/mcp/handler.rs`. Update `test_handle_initialize` to also assert the `resources` capability is present. + +- [ ] **Step 5: Verify** + +Run: `cargo test mcp::handler` +Expected: All six tests PASS (with the updated capabilities assertion). + +Run: `cargo test mcp::transport` +Expected: Compiles, no tests left in transport.rs. + +Run: `cargo clippy -- -D warnings` +Expected: No warnings. + +- [ ] **Step 6: Stop for user commit review** + +This task is structurally complete (refactor only, plus the additive resources capability). Surface the diff to the user. + +--- + +### Task 2: Add stdio transport + +**Files:** +- Create: `src/mcp/stdio.rs` +- Test: inline `#[cfg(test)]` block + +- [ ] **Step 1: Write a failing test for one round-trip** + +In `src/mcp/stdio.rs`, write the function shell and a test that pipes a JSON-RPC request through it. The test uses a `Vec` for both input and output. Tests use `tempfile::TempDir` because `ApiState::new` initializes templates on disk. + +```rust +//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout. +//! +//! Each line on stdin is one JSON-RPC request. Each response is one JSON +//! object written to stdout terminated by `\n`. Logs and diagnostics go to +//! stderr (via `tracing`). This is the transport MCP clients use when they +//! spawn `operator mcp` as a subprocess. + +use std::io; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +use crate::rest::state::ApiState; + +/// Run the stdio MCP loop until stdin closes. +/// +/// `reader`/`writer` are generic for testability; production callers pass +/// `tokio::io::stdin()` and `tokio::io::stdout()`. +pub async fn run(state: ApiState, reader: R, mut writer: W) -> io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut lines = BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let request: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, line = %line, "Malformed JSON-RPC request"); + continue; + } + }; + let response = handle_jsonrpc(&request, &state).await; + let json = serde_json::to_string(&response) + .unwrap_or_else(|_| r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"#.to_string()); + writer.write_all(json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + // ApiState::new writes default templates into tickets_path; tempdir handles cleanup. + ApiState::new(Config::default(), temp.path().to_path_buf()) + } + + #[tokio::test] + async fn test_stdio_roundtrip_initialize() { + let state = test_state(); + let input = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} +"#; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + + let response_str = std::str::from_utf8(&output).unwrap(); + let response: serde_json::Value = serde_json::from_str(response_str.trim()).unwrap(); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["serverInfo"]["name"], "operator"); + } + + #[tokio::test] + async fn test_stdio_ignores_blank_lines() { + let state = test_state(); + let input = b"\n\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + assert!(output.is_empty()); + } + + #[tokio::test] + async fn test_stdio_malformed_line_is_skipped() { + let state = test_state(); + let input = b"not json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + let response_str = std::str::from_utf8(&output).unwrap(); + // Only one response should be present (the valid one) + assert_eq!(response_str.matches('\n').count(), 1); + } +} +``` + +Confirm `tempfile` is already a dev-dependency (it's used elsewhere in the project). If not, add `tempfile = "3"` under `[dev-dependencies]`. + +- [ ] **Step 2: Run the tests** + +Run: `cargo test mcp::stdio` +Expected: 3 tests PASS. + +- [ ] **Step 3: Stop for commit review** + +--- + +### Task 3: Add `operator mcp` CLI subcommand + +**Files:** +- Modify: `src/main.rs` (add variant, match arm, async fn) + +- [ ] **Step 1: Add the `Mcp` variant to the `Commands` enum** + +Edit `src/main.rs`. Insert after the `Api { port: Option }` variant (around line 230): + +```rust + /// Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, JetBrains, etc.). + /// + /// Reads line-delimited JSON-RPC from stdin and writes responses to stdout. + /// Log output goes to stderr. Intended to be spawned by an MCP-capable client. + Mcp, +``` + +- [ ] **Step 2: Add the match arm** + +In the main `match cli.command` block (around `src/main.rs:281`), add a new arm before `Some(Commands::Setup { ... })`: + +```rust + Some(Commands::Mcp) => { + cmd_mcp(&config).await?; + } +``` + +- [ ] **Step 3: Implement `cmd_mcp`** + +Add to the bottom of `src/main.rs`, alongside `cmd_api`: + +```rust +async fn cmd_mcp(config: &Config) -> Result<()> { + use crate::rest::state::ApiState; + let state = ApiState::new(config.clone(), config.tickets_path()); + tracing::info!("Starting MCP stdio server"); + crate::mcp::stdio::run(state, tokio::io::stdin(), tokio::io::stdout()).await?; + tracing::info!("MCP stdio server stopped (stdin closed)"); + Ok(()) +} +``` + +- [ ] **Step 4: Verify it runs and responds** + +Run: `cargo build --release` +Run interactively in a shell: +``` +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | ./target/release/operator mcp +``` +Expected: One line of JSON output containing `"serverInfo":{"name":"operator"`. Process exits cleanly after stdin closes. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 4: Add ticket-queue MCP read tool (`operator_list_tickets`) + +**Files:** +- Create: `src/mcp/tickets.rs` +- Modify: `src/mcp/tools.rs:23-99` (definitions), `:101-150` (dispatch), `:162` (count assertion) + +- [ ] **Step 1: Write a failing test for `operator_list_tickets`** + +In `src/mcp/tickets.rs`: + +```rust +//! Ticket-queue MCP tools. +//! +//! Reads/writes via `crate::queue::Queue` which uses blocking `std::fs`, +//! so all calls are wrapped in `tokio::task::spawn_blocking`. + +use serde_json::{json, Value}; + +use crate::queue::ticket::Ticket; +use crate::queue::Queue; +use crate::rest::state::ApiState; + +fn ticket_to_json(t: &Ticket) -> Value { + json!({ + "id": t.id, + "filename": t.filename, + "project": t.project, + "ticket_type": t.ticket_type, + "summary": t.summary, + "priority": t.priority, + "status": t.status, + "branch": t.branch, + "external_id": t.external_id, + "external_url": t.external_url, + "external_provider": t.external_provider, + }) +} + +pub async fn list_tickets(args: Value, state: &ApiState) -> Result { + let status = args.get("status").and_then(|v| v.as_str()).unwrap_or("queue").to_string(); + let config = (*state.config).clone(); + let tickets = tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + match status.as_str() { + "queue" => queue.list_queue().map_err(|e| e.to_string()), + "in-progress" => queue.list_in_progress().map_err(|e| e.to_string()), + "completed" => queue.list_completed().map_err(|e| e.to_string()), + other => Err(format!("Unknown ticket status: {other}")), + } + }) + .await + .map_err(|e| e.to_string())??; + + let json_tickets: Vec = tickets.iter().map(ticket_to_json).collect(); + Ok(json!({ "tickets": json_tickets, "count": json_tickets.len() })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + // Leak the tempdir so it survives the test; in real test isolation use a guard. + let path = temp.into_path(); + ApiState::new(Config::default(), path) + } + + #[tokio::test] + async fn test_list_tickets_empty_queue() { + let state = test_state(); + let result = list_tickets(json!({}), &state).await.unwrap(); + assert_eq!(result["count"], 0); + } + + #[tokio::test] + async fn test_list_tickets_unknown_status_errors() { + let state = test_state(); + let err = list_tickets(json!({ "status": "bogus" }), &state).await.unwrap_err(); + assert!(err.contains("Unknown ticket status")); + } +} +``` + +Verify the actual `Queue::new` signature first (Pre-Flight #1). If it takes a different argument (e.g. `&Config` vs. owned `Config`), adjust the closure capture. The `(*state.config).clone()` pattern handles the `Arc` deref. + +Run: `cargo test mcp::tickets::tests::test_list_tickets_empty_queue` +Expected: PASS. + +- [ ] **Step 2: Register the tool in `tools.rs`** + +In `src/mcp/tools.rs:23-99`, append to the `vec!` in `all_tool_definitions`: + +```rust + McpToolDefinition { + name: "operator_list_tickets".to_string(), + description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["queue", "in-progress", "completed"], "default": "queue" } + }, + "required": [] + }), + }, +``` + +In `execute_tool` (around `src/mcp/tools.rs:103`), add the dispatch arm before the catch-all `_ =>`: + +```rust + "operator_list_tickets" => crate::mcp::tickets::list_tickets(args, state).await, +``` + +- [ ] **Step 3: Update the tool-count assertions** + +In `src/mcp/handler.rs::tests::test_handle_tools_list` (moved in Task 1, Step 4) and `src/mcp/tools.rs:162`'s count assertion, change the expected count from `7` to `8`. + +- [ ] **Step 4: Run everything** + +Run: `cargo test mcp::` +Expected: All MCP tests PASS. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 5: Add ticket-queue MCP write tools (claim, complete, return-to-queue) + +**Files:** +- Modify: `src/mcp/tickets.rs` (three new fns + tests) +- Modify: `src/mcp/tools.rs` (three definitions, three dispatch arms, count assertion → 11) + +All three write tools follow the same pattern: look up the ticket by `id` in the appropriate source list, call the corresponding `Queue` method on it, return the new path. They share a permission gate on `config.mcp.expose_ticket_write_tools` (added in Task 7). + +- [ ] **Step 1: Add the shared lookup helper in `tickets.rs`** + +```rust +async fn find_ticket(state: &ApiState, id: &str, in_status: &str) -> Result { + let id = id.to_string(); + let in_status = in_status.to_string(); + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match in_status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + list.into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("Ticket {id} not found in {in_status}")) + }) + .await + .map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 2: Implement `claim_ticket`** + +```rust +pub async fn claim_ticket(args: Value, state: &ApiState) -> Result { + let id = args.get("id").and_then(|v| v.as_str()).ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "queue").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.claim_ticket(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "in-progress" })) +} +``` + +Add test that creates a temp tickets dir, writes a fake ticket file into `queue/`, calls `claim_ticket`, then asserts the file exists in `in-progress/` and not in `queue/`. Use a real timestamped filename matching the project's expected pattern (see `src/queue/ticket.rs` for parse rules). + +- [ ] **Step 3: Implement `complete_ticket` and `return_to_queue`** + +Identical shape — source status is `"in-progress"` for both, target differs: + +```rust +pub async fn complete_ticket(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.complete_ticket */ } +pub async fn return_to_queue(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.return_to_queue */ } +``` + +Add a test for each. + +- [ ] **Step 4: Register the three tools in `tools.rs` with the permission gate** + +Append three `McpToolDefinition` entries to `all_tool_definitions`: + +```rust + McpToolDefinition { + name: "operator_claim_ticket".to_string(), + description: "Move a ticket from queue to in-progress. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string", "description": "Ticket id (e.g. FEAT-1234)" } }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_complete_ticket".to_string(), + description: "Move a ticket from in-progress to completed.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_return_to_queue".to_string(), + description: "Move a ticket from in-progress back to queue (un-claim).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }), + }, +``` + +In `execute_tool`, add three dispatch arms with the shared permission gate. Extract the gate to a helper: + +```rust +fn require_write_tools(state: &ApiState) -> Result<(), String> { + if !state.config.mcp.expose_ticket_write_tools { + Err("Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)".to_string()) + } else { + Ok(()) + } +} +``` + +```rust + "operator_claim_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::claim_ticket(args, state).await + } + "operator_complete_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::complete_ticket(args, state).await + } + "operator_return_to_queue" => { + require_write_tools(state)?; + crate::mcp::tickets::return_to_queue(args, state).await + } +``` + +- [ ] **Step 5: Add a gate test** + +In `tickets.rs::tests`, assert that `claim_ticket` returns the gate error when `config.mcp.expose_ticket_write_tools = false`. Construct the state with a config where the flag is false, then call `execute_tool("operator_claim_ticket", ...)` and assert the error string. + +- [ ] **Step 6: Update tool-count assertions to 11** + +(8 existing + 3 new write tools = 11. Task 5.5 will add a 12th, Task 6 doesn't add tools.) + +- [ ] **Step 7: Verify** + +Run: `cargo test mcp::` +Expected: All MCP tests PASS. + +- [ ] **Step 8: Stop for commit review** + +--- + +### Task 5.5: Add `operator_create_ticket` write tool + +**Files:** +- Modify: `src/queue/creator.rs` (add `create_ticket_headless`) +- Modify: `src/mcp/tickets.rs` (add `create_ticket` MCP fn) +- Modify: `src/mcp/tools.rs` (one definition, one dispatch arm, count → 12) + +The existing `TicketCreator::create_ticket_with_values` (lines 33-68) opens `$EDITOR` after writing the file. That's wrong for MCP — there's no terminal. Add a headless variant that returns the path without launching an editor. + +- [ ] **Step 1: Add `create_ticket_headless` to `TicketCreator`** + +In `src/queue/creator.rs`, beside the existing `create_ticket_with_values`, add: + +```rust +/// Create a ticket without opening it in an editor (for MCP / API use). +pub fn create_ticket_headless( + &self, + template_type: TemplateType, + values: &HashMap, +) -> Result { + let now = Utc::now(); + let timestamp = now.format("%Y%m%d-%H%M").to_string(); + let type_str = template_type.as_str(); + let project = values + .get("project") + .filter(|p| !p.is_empty()) + .cloned() + .unwrap_or_else(|| "global".to_string()); + + let filename = format!("{timestamp}-{type_str}-{project}-new-ticket.md"); + let filepath = self.queue_path.join(&filename); + + let template = template_type.template_content(); + let content = render_template(template, values)?; + fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; + fs::write(&filepath, &content).context("Failed to write ticket file")?; + + Ok(filepath) +} +``` + +Refactor `create_ticket_with_values` to call `create_ticket_headless` and then `open_in_editor` (DRY). Run existing tests to confirm no regression. + +- [ ] **Step 2: Add MCP fn `create_ticket` in `tickets.rs`** + +```rust +pub async fn create_ticket(args: Value, state: &ApiState) -> Result { + use crate::queue::creator::TicketCreator; + use crate::templates::TemplateType; + use std::collections::HashMap; + + let template_str = args.get("template").and_then(|v| v.as_str()).ok_or("Missing required arg: template")?; + let template_type = TemplateType::from_str(template_str).map_err(|e| e.to_string())?; + let mut values: HashMap = HashMap::new(); + if let Some(obj) = args.get("values").and_then(|v| v.as_object()) { + for (k, v) in obj { + if let Some(s) = v.as_str() { + values.insert(k.clone(), s.to_string()); + } + } + } + + let config = (*state.config).clone(); + let path = tokio::task::spawn_blocking(move || -> Result { + let creator = TicketCreator::new(&config); + creator.create_ticket_headless(template_type, &values).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(json!({ "path": path.to_string_lossy(), "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or("") })) +} +``` + +Verify `TemplateType::from_str` exists. If not, use the project's actual enum-parsing convention. + +- [ ] **Step 3: Register `operator_create_ticket` in `tools.rs`** + +```rust + McpToolDefinition { + name: "operator_create_ticket".to_string(), + description: "Create a new ticket from a template (FEAT, FIX, INV, SPIKE, etc.) and write it to the queue. Returns the filename. Gated by [mcp].expose_ticket_write_tools.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template": { "type": "string", "description": "Template type (FEAT, FIX, INV, SPIKE, ...)" }, + "values": { "type": "object", "description": "Handlebars values for the template (project, summary, etc.)" } + }, + "required": ["template"] + }), + }, +``` + +```rust + "operator_create_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::create_ticket(args, state).await + } +``` + +- [ ] **Step 4: Add test** + +Temp tickets dir, call `create_ticket` with `template = "FEAT", values = { "summary": "test", "project": "demo" }`, assert a `.md` file lands in `queue/` with a name containing `demo`. + +- [ ] **Step 5: Update tool-count assertions to 12** + +- [ ] **Step 6: Verify** + +Run: `cargo test mcp::` +Expected: PASS. + +- [ ] **Step 7: Stop for commit review** + +--- + +### Task 6: MCP resources capability — expose tickets as resources + +**Files:** +- Modify: `src/mcp/handler.rs` (add `resources/list` and `resources/read` handlers; the capability is already advertised after Task 1 Step 1) +- Create: `src/mcp/resources.rs` + +MCP clients can subscribe to resources to read context. Expose each ticket as a resource with URI `operator://tickets/{status}/{id}`. This is the highest-leverage capability for IDEs that want to surface tickets natively. + +- [ ] **Step 1: Add `resources/list` and `resources/read` handlers** + +After the `tools/call` arm in `handle_jsonrpc`: + +```rust + "resources/list" => { + let resources = crate::mcp::resources::list_resources(state).await + .unwrap_or_else(|_| vec![]); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "resources": resources })), + error: None, + } + } + "resources/read" => { + let uri = request.params.get("uri").and_then(|v| v.as_str()).unwrap_or(""); + match crate::mcp::resources::read_resource(uri, state).await { + Ok(contents) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "contents": [{ "uri": uri, "mimeType": "text/markdown", "text": contents }] })), + error: None, + }, + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { code: -32000, message: e }), + }, + } + } +``` + +- [ ] **Step 2: Implement `src/mcp/resources.rs` via `Queue`** + +```rust +//! MCP resources — exposes tickets as URI-addressable resources. + +use serde_json::{json, Value}; + +use crate::queue::Queue; +use crate::rest::state::ApiState; + +pub async fn list_resources(state: &ApiState) -> Result, String> { + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let mut all = Vec::new(); + for (status, list) in [ + ("queue", queue.list_queue()), + ("in-progress", queue.list_in_progress()), + ("completed", queue.list_completed()), + ] { + for t in list.map_err(|e| e.to_string())? { + all.push(json!({ + "uri": format!("operator://tickets/{status}/{}", t.id), + "name": t.filename, + "mimeType": "text/markdown", + "description": t.summary, + })); + } + } + Ok(all) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn read_resource(uri: &str, state: &ApiState) -> Result { + let prefix = "operator://tickets/"; + let rest = uri.strip_prefix(prefix).ok_or_else(|| format!("Unknown URI scheme: {uri}"))?; + let (status, id) = rest.split_once('/').ok_or_else(|| format!("Malformed URI: {uri}"))?; + + let config = (*state.config).clone(); + let status = status.to_string(); + let id = id.to_string(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + let ticket = list.into_iter().find(|t| t.id == id).ok_or_else(|| format!("Ticket {id} not found"))?; + std::fs::read_to_string(&ticket.filepath).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + ApiState::new(Config::default(), temp.into_path()) + } + + #[tokio::test] + async fn test_list_resources_empty() { + let state = test_state(); + let resources = list_resources(&state).await.unwrap(); + assert!(resources.is_empty()); + } + + #[tokio::test] + async fn test_read_resource_unknown_scheme() { + let state = test_state(); + let err = read_resource("file:///tmp/x", &state).await.unwrap_err(); + assert!(err.contains("Unknown URI scheme")); + } + + #[tokio::test] + async fn test_read_resource_malformed() { + let state = test_state(); + let err = read_resource("operator://tickets/queue", &state).await.unwrap_err(); + assert!(err.contains("Malformed URI")); + } +} +``` + +- [ ] **Step 3: Verify** + +Run: `cargo test mcp::` +Expected: All PASS. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 6.5: Extend `McpDescriptorResponse` with stdio command (vscode-extension Phase 2 enabler) + +**Files:** +- Modify: `src/mcp/descriptor.rs` + +The existing descriptor at `src/mcp/descriptor.rs:14-30` is consumed by `vscode-extension/src/mcp-connect.ts` to register operator as an SSE MCP server. Extending it with an optional `stdio` field is purely additive (gated by `skip_serializing_if`) and unlocks the Phase 2 work where the extension can choose to spawn `operator mcp` instead of (or alongside) the SSE transport. + +- [ ] **Step 1: Add `StdioCommand` and the optional field** + +Edit `src/mcp/descriptor.rs`: + +```rust +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StdioCommand { + /// Absolute path to the operator binary (the same binary serving this descriptor) + pub command: String, + /// Args to pass: typically ["mcp"] + pub args: Vec, + /// Working directory the client should set when spawning. Defaults to the + /// operator process's current working directory. + pub cwd: String, +} + +// Add to McpDescriptorResponse: + /// Stdio transport entrypoint. Present when [mcp].stdio_advertised = true. + /// Clients may spawn this as a subprocess instead of using transport_url. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdio: Option, +``` + +- [ ] **Step 2: Inject `State` into the handler and populate `stdio`** + +Change the handler signature from `descriptor(Host(host): Host)` to `descriptor(State(state): State, Host(host): Host)` and populate: + +```rust +pub async fn descriptor( + State(state): State, + Host(host): Host, +) -> Json { + let base = format!("http://{host}"); + + let stdio = if state.config.mcp.stdio_advertised { + let command = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| "operator".to_string()); + let cwd = std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_default(); + Some(StdioCommand { + command, + args: vec!["mcp".to_string()], + cwd, + }) + } else { + None + }; + + Json(McpDescriptorResponse { + server_name: "operator".to_string(), + server_id: "operator-mcp".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + transport_url: format!("{base}/api/v1/mcp/sse"), + label: "Operator MCP Server".to_string(), + openapi_url: Some(format!("{base}/api-docs/openapi.json")), + stdio, + }) +} +``` + +The route registration in `src/rest/mod.rs` already passes `ApiState` (other handlers use it), but verify the descriptor's route line and add the `State` extractor if it currently uses a different shape. + +- [ ] **Step 3: Update existing descriptor tests + add stdio coverage** + +Update both `test_descriptor_response` and `test_descriptor_custom_port` to construct a test `ApiState` and pass it. Add two new tests: +- `test_descriptor_stdio_present_when_advertised` — config with `stdio_advertised = true` → `resp.stdio.is_some()` and `resp.stdio.unwrap().args == vec!["mcp"]`. +- `test_descriptor_stdio_absent_when_disabled` — config with `stdio_advertised = false` → `resp.stdio.is_none()`. + +(Both tests need Task 7's `McpConfig` to exist. Either land Task 7 first, or temporarily inline the field default behind a feature flag. **Preferred order:** Task 7 before Task 6.5 — see ordering note below.) + +- [ ] **Step 4: Regenerate TypeScript bindings** + +Run: `cargo test` — `ts-rs` regenerates `bindings/` (or wherever the project configures `#[ts(export)]` output) including the new `StdioCommand` type. + +- [ ] **Step 5: Verify** + +Run: `cargo test mcp::descriptor` +Expected: PASS, including the two new stdio tests. + +Run: `cargo clippy -- -D warnings` +Expected: clean. + +- [ ] **Step 6: Stop for commit review** + +> **Ordering note:** This task reads `config.mcp.stdio_advertised`, which is defined in Task 7. If you're executing strictly in numeric order, swap: do Task 7 first, then return to Task 6.5. Tasks 1-6 are independent of `McpConfig`. + +--- + +### Task 7: Add `[mcp]` config section + +**Files:** +- Modify: `src/config.rs` (Config struct around lines 28-74; new `McpConfig` struct alongside `RestApiConfig` and `RelayConfig`) + +- [ ] **Step 1: Add the `McpConfig` struct** + +Add in `src/config.rs`, near other sub-structs like `RestApiConfig` (`src/config.rs:263-291`) and `RelayConfig`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export)] +pub struct McpConfig { + /// Whether to mount MCP HTTP/SSE endpoints on the REST API server. + /// Toggling requires an API restart (no hot-swap of the axum router). + #[serde(default = "default_true")] + pub http_enabled: bool, + /// Whether the descriptor endpoint advertises the `operator mcp` stdio + /// command. Set to false on multi-tenant/remote deployments where clients + /// shouldn't spawn local subprocesses. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + /// create) over MCP. Defaults to `false` because any MCP client can call them. + #[serde(default)] + pub expose_ticket_write_tools: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + http_enabled: true, + stdio_advertised: true, + expose_ticket_write_tools: false, + } + } +} + +fn default_true() -> bool { true } +``` + +The `JsonSchema + TS` derive pair matches the rest of the codebase (`src/config.rs`'s other structs). The `TS` derive triggers TypeScript binding regeneration consumed by `vscode-extension/scripts/copy-types.js`. + +- [ ] **Step 2: Add the field to `Config`** + +Add to the `Config` struct (alongside `relay: RelayConfig`): + +```rust + #[serde(default)] + pub mcp: McpConfig, +``` + +Update `Config::default()` to include `mcp: McpConfig::default()`. + +- [ ] **Step 3: Wire `http_enabled` into the router build** + +In `src/rest/mod.rs` (around lines 175-181, where MCP routes are currently mounted unconditionally), wrap the MCP route registrations: + +```rust +if state.config.mcp.http_enabled { + router = router + .route("/api/v1/mcp/descriptor", get(descriptor::descriptor)) + .route("/api/v1/mcp/sse", get(transport::sse_handler)) + .route("/api/v1/mcp/message", post(transport::message_handler)); +} +``` + +(Exact shape depends on the existing router-building pattern. Verify by reading `src/rest/mod.rs:175-181`.) + +- [ ] **Step 4: Verify the gate from Task 5 now compiles** + +Run: `cargo test mcp::` +Expected: PASS, including the disabled-write-tools gate test from Task 5. + +- [ ] **Step 5: Regen docs and TS bindings** + +``` +cargo run -- docs --only config # regenerates docs/configuration/index.md with [mcp] section +cargo test # ts-rs regenerates bindings +``` + +Then refresh the vscode-extension's copy (Phase 2 will need this): + +``` +cd vscode-extension && npm run copy-types +``` + +If `copy-types` isn't yet a script in `package.json`, fall back to the manual path the project uses (`scripts/copy-types.js`). + +Verify the generated `docs/configuration/index.md` now describes `[mcp]`. + +- [ ] **Step 6: Stop for commit review** + +--- + +### Task 8: Client config snippet generator + +**Files:** +- Create: `src/mcp/client_configs.rs` + +Operator users adopting MCP need to be told "paste this into your client config." Generate snippets at runtime so they always carry the correct absolute path to the operator binary and the project's working directory. + +- [ ] **Step 0: Verify modern client config shapes** + +Before writing snippets, confirm the current expected shape for each: +- Run: `head -100 vscode-extension/src/mcp-connect.ts` — verify what the extension currently writes to workspace `mcp.servers` (or the modern `.vscode/mcp.json` `servers` shape). The snippet for VS Code must match what the extension expects. +- Cursor: `~/.cursor/mcp.json` uses the `mcpServers` shape (same as Claude Code's `claude.json`). +- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` uses `mcpServers`. +- Zed: `settings.json` under `context_servers`. + +If any shape has drifted, update the snippet in Step 1 before testing. + +- [ ] **Step 1: Implement `client_configs.rs`** + +```rust +//! Generates copy-paste MCP client configuration snippets pointing at this operator binary. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +pub fn current_exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +fn mcp_servers_shape(cwd: &Path) -> Value { + // Used by Claude Code (~/.claude.json), Claude Desktop, and Cursor (~/.cursor/mcp.json). + json!({ + "mcpServers": { + "operator": { + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +pub fn claude_code_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } +pub fn claude_desktop_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } + +/// Cursor's `~/.cursor/mcp.json` uses the same `mcpServers` shape as Claude Code. +pub fn cursor_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } + +/// VS Code (1.94+) per-workspace `.vscode/mcp.json` uses a `servers` block with explicit `type`. +pub fn vscode_snippet(cwd: &Path) -> Value { + json!({ + "servers": { + "operator": { + "type": "stdio", + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +/// Zed config under `context_servers` in user settings. +pub fn zed_snippet(cwd: &Path) -> Value { + json!({ + "context_servers": { + "operator": { + "command": { "path": current_exe().to_string_lossy(), "args": ["mcp"], "env": {} }, + "settings": { "cwd": cwd.to_string_lossy() } + } + } + }) +} + +/// Dispatch by client name. Returns `None` for unknown clients. +pub fn snippet_for(client: &str, cwd: &Path) -> Option { + match client { + "claude-code" => Some(claude_code_snippet(cwd)), + "claude-desktop" => Some(claude_desktop_snippet(cwd)), + "cursor" => Some(cursor_snippet(cwd)), + "vscode" => Some(vscode_snippet(cwd)), + "zed" => Some(zed_snippet(cwd)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claude_code_snippet_shape() { + let cfg = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["mcpServers"]["operator"]["args"][0], "mcp"); + assert_eq!(cfg["mcpServers"]["operator"]["cwd"], "/work"); + } + + #[test] + fn test_cursor_snippet_matches_claude_code() { + let cursor = cursor_snippet(&PathBuf::from("/work")); + let claude = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cursor, claude); + } + + #[test] + fn test_vscode_snippet_uses_servers_with_type() { + let cfg = vscode_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["servers"]["operator"]["type"], "stdio"); + assert_eq!(cfg["servers"]["operator"]["args"][0], "mcp"); + } + + #[test] + fn test_zed_snippet_uses_context_servers() { + let cfg = zed_snippet(&PathBuf::from("/work")); + assert!(cfg["context_servers"]["operator"]["command"]["path"].is_string()); + } + + #[test] + fn test_snippet_for_unknown_client_is_none() { + assert!(snippet_for("notepad++", &PathBuf::from("/w")).is_none()); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test mcp::client_configs` +Expected: PASS. + +- [ ] **Step 3: Stop for commit review** + +--- + +### Task 9: Integrate MCP into `StatusSnapshot` and `ConnectionsSection` + +**Files:** +- Modify: `src/ui/status_panel.rs:401-431` (StatusSnapshot fields), `:143-174` (StatusAction) +- Modify: `src/ui/sections/connections_section.rs:70-138` (children) +- Modify: `src/ui/dashboard.rs:203-317` (snapshot construction) +- Modify: `src/app/status_actions.rs` (action handlers) + +Mirror the existing `Operator API` row pattern exactly. No new clipboard dependency — `WriteAndOpenMcpClientConfig` writes the snippet to a file and dispatches the existing `EditFile(path)` action. + +- [ ] **Step 1: Add `McpHttpStatus` enum** + +In `src/rest/server.rs` (alongside `RestApiStatus`) or a new `src/mcp/status.rs` if you prefer to keep MCP types together: + +```rust +#[derive(Debug, Clone, PartialEq)] +pub enum McpHttpStatus { + /// MCP HTTP routes mounted on the REST API server on the given port. + Mounted { port: u16 }, + /// MCP HTTP routes disabled via [mcp].http_enabled = false. + NotMounted, +} +``` + +- [ ] **Step 2: Add MCP fields to `StatusSnapshot`** + +In `src/ui/status_panel.rs` around line 425, before the closing `}`: + +```rust + /// MCP HTTP transport status (mounted on the API server, or disabled by config). + pub mcp_http_status: McpHttpStatus, + /// Whether the descriptor advertises the stdio entrypoint. + pub mcp_stdio_advertised: bool, + /// Currently active MCP SSE sessions on the HTTP transport. + pub mcp_active_sessions: usize, +``` + +- [ ] **Step 3: Add new `StatusAction` variants** + +In `src/ui/status_panel.rs:143-174`, add before `None`: + +```rust + /// Toggle [mcp].http_enabled (requires API restart to take effect). + ToggleMcpHttp, + /// Generate a client config snippet, write it to /operator/mcp/.json, + /// and open it in $EDITOR. `client` is one of: "claude-code", "claude-desktop", "cursor", "vscode", "zed". + WriteAndOpenMcpClientConfig { client: String }, + /// Open the operator MCP docs page in the default browser. + OpenMcpDocs, +``` + +- [ ] **Step 4: Add the "MCP" row in `ConnectionsSection::children`** + +In `src/ui/sections/connections_section.rs` after the "Operator API" row push (around line 117), insert: + +```rust + rows.push(TreeRow { + section_id: SectionId::Connections, + depth: 1, + label: "MCP".into(), + description: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised, snapshot.mcp_active_sessions) { + (McpHttpStatus::Mounted { port }, true, n) if n > 0 => format!(":{port} + stdio · {n} sessions"), + (McpHttpStatus::Mounted { port }, true, _) => format!(":{port} + stdio"), + (McpHttpStatus::Mounted { port }, false, _) => format!(":{port} (HTTP only)"), + (McpHttpStatus::NotMounted, true, _) => "stdio only".into(), + (McpHttpStatus::NotMounted, false, _) => "Disabled".into(), + }, + icon: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised) { + (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Plug, + _ => StatusIcon::Cross, + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::WriteAndOpenMcpClientConfig { client: "claude-code".to_string() }, + back: StatusAction::None, + special: StatusAction::ToggleMcpHttp, + special_meta: Some(ActionMeta { title: "HTTP", tooltip: "Toggle the MCP HTTP transport (restart required)" }), + refresh: StatusAction::OpenMcpDocs, + refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open MCP setup docs in browser" }), + }, + health: SectionHealth::Gray, + }); +``` + +`McpHttpStatus` and `StatusIcon::Plug` need imports added at the top of the file. + +- [ ] **Step 5: Populate the snapshot in `dashboard.rs`** + +In `src/ui/dashboard.rs:203-317`'s `build_status_snapshot`, populate the three new fields: + +```rust + mcp_http_status: if self.config.mcp.http_enabled { + match &self.rest_api_status { + RestApiStatus::Running { port } => McpHttpStatus::Mounted { port: *port }, + _ => McpHttpStatus::NotMounted, + } + } else { + McpHttpStatus::NotMounted + }, + mcp_stdio_advertised: self.config.mcp.stdio_advertised, + mcp_active_sessions: self.api_state.as_ref() + .map(|s| s.mcp_sessions.try_lock().map(|m| m.len()).unwrap_or(0)) + .unwrap_or(0), +``` + +Verify the `Dashboard` struct's field that holds `ApiState` (might not be `api_state`; grep for `ApiState` in `src/ui/dashboard.rs`). If the dashboard doesn't currently hold an `ApiState` reference, route the session count through the `RestApiServer` lifecycle handle (which already holds the state) or default to `0` until the API is running. + +- [ ] **Step 6: Wire the action handlers in `src/app/status_actions.rs`** + +Add three new match arms in the existing dispatcher (around `src/app/status_actions.rs:66`): + +```rust +StatusAction::ToggleMcpHttp => { + // Flip config.mcp.http_enabled in the running Config and surface a notice. + // No hot-swap: tell the user to restart the API. + self.config.mcp.http_enabled = !self.config.mcp.http_enabled; + self.dashboard.set_status(if self.config.mcp.http_enabled { + "MCP HTTP enabled — restart the API to mount routes" + } else { + "MCP HTTP disabled — restart the API to unmount routes" + }); +} + +StatusAction::WriteAndOpenMcpClientConfig { client } => { + use crate::mcp::client_configs; + let cwd = std::env::current_dir().unwrap_or_default(); + let Some(snippet) = client_configs::snippet_for(&client, &cwd) else { + self.dashboard.set_status(&format!("Unknown MCP client: {client}")); + return; + }; + let dir = self.config.tickets_path().join("operator/mcp"); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dashboard.set_status(&format!("Failed to create {}: {e}", dir.display())); + return; + } + let path = dir.join(format!("{client}.json")); + let body = serde_json::to_string_pretty(&snippet).unwrap_or_default(); + if let Err(e) = std::fs::write(&path, body) { + self.dashboard.set_status(&format!("Failed to write {}: {e}", path.display())); + return; + } + // Reuse the existing EditFile dispatcher. + self.dispatch(StatusAction::EditFile(path.to_string_lossy().into_owned())); +} + +StatusAction::OpenMcpDocs => { + // Use the existing open_in_browser helper. + if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") { + self.dashboard.set_status(&format!("Failed to open docs: {e}")); + } +} +``` + +Confirm the docs URL before merging (TODO marker; pick the actual operator docs URL). + +- [ ] **Step 7: Update all test snapshots** + +Search test files for `StatusSnapshot {` (`rg "StatusSnapshot \{" --type rust`) and add the three new fields with defaults: + +```rust + mcp_http_status: McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, +``` + +- [ ] **Step 8: Add a test for the new MCP row** + +Append to `src/ui/sections/connections_section.rs::tests`: + +```rust + #[test] + fn test_connections_mcp_row_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP"); + assert!(mcp_row.is_some(), "MCP row should always be present"); + } + + #[test] + fn test_connections_mcp_row_description_disabled() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = false; + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + assert_eq!(mcp_row.description, "Disabled"); + } +``` + +- [ ] **Step 9: Verify** + +Run: `cargo fmt && cargo clippy -- -D warnings && cargo test` +Expected: All PASS, no warnings. + +- [ ] **Step 10: Stop for commit review** + +--- + +### Task 10: End-to-end integration test + +**Files:** +- Create: `tests/mcp_stdio_integration.rs` + +- [ ] **Step 1: Write the test** + +```rust +//! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip +//! a real JSON-RPC handshake. + +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn test_operator_mcp_stdio_initialize_and_list_tools() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator mcp"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n").await.unwrap(); + stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let line1 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap(); + let resp1: serde_json::Value = serde_json::from_str(&line1).unwrap(); + assert_eq!(resp1["id"], 1); + assert_eq!(resp1["result"]["serverInfo"]["name"], "operator"); + + let line2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap(); + let resp2: serde_json::Value = serde_json::from_str(&line2).unwrap(); + assert_eq!(resp2["id"], 2); + // 8 read + 4 write tools = 12 (or whichever count Task 5.5 left it at) + assert!(resp2["result"]["tools"].as_array().unwrap().len() >= 8); + + drop(stdin); + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test --test mcp_stdio_integration -- --nocapture` +Expected: PASS (a few seconds to build the binary the first time). + +- [ ] **Step 3: Final verification** + +``` +cargo fmt +cargo clippy -- -D warnings +cargo test +cargo run -- docs --only config # confirm [mcp] appears in regenerated docs +cd vscode-extension && npm run copy-types && cd .. # confirm new TS types regenerated +``` + +Expected: green across the board, generated docs and TS bindings updated. + +- [ ] **Step 4: Stop for user to commit** + +--- + +## Integration Handoff (sets up Phase 2 + Phase 3) + +This plan does **not** modify vscode-extension or write a Cursor integration. It sets the stage so the next two plans can be written and executed independently. + +**Phase 2 — vscode-extension refinement (separate follow-up plan):** +- The existing `vscode-extension/src/mcp-connect.ts:34-97` already consumes `/api/v1/mcp/descriptor`. After Task 6.5, the response carries an optional `stdio: StdioCommand` field. +- Phase 2 will modify `mcp-connect.ts` to: (a) detect the new field, (b) offer a workspace setting `operator.mcpTransport: "sse" | "stdio" | "auto"`, (c) when stdio is chosen/auto-selected, register operator via VS Code's modern MCP API as a stdio server using `descriptor.stdio.command` + `descriptor.stdio.args` + `descriptor.stdio.cwd`. Fallback remains SSE. +- The TypeScript binding for `StdioCommand` will be available via the existing `scripts/copy-types.js` flow once Task 7 Step 5 runs. + +**Phase 3 — Cursor integration (separate follow-up plan):** +- Cursor has no extension; it consumes `~/.cursor/mcp.json` directly. Task 8's `cursor_snippet()` already produces the right shape. +- Phase 3 will add either: (a) a `operator mcp install --client cursor` CLI subcommand that writes the snippet to `~/.cursor/mcp.json` (merging with existing servers), or (b) a docs page rendering the snippet with the current binary path, or (c) both. The dashboard's `WriteAndOpenMcpClientConfig { client: "cursor" }` action (Task 9) already covers the local-workspace path. +- Phase 3 should also document the JetBrains/Claude Desktop install flow using the same `snippet_for(client, cwd)` dispatch since those clients use the same `mcpServers` shape. + +--- + +## Self-Review + +**Spec coverage:** +- Stdio transport — Tasks 2, 3 +- Expanded tool surface (read + 4 write tools) — Tasks 4, 5, 5.5 +- Resources capability — Task 6 +- Descriptor stdio handoff for vscode-extension — Task 6.5 +- Config section — Task 7 +- Client config snippets — Task 8 +- Status integration (toggle + write-and-open snippet + docs link) — Task 9 +- End-to-end test — Task 10 +- Phase 2/3 handoff — Integration Handoff section + +**Open assumptions verified in Pre-Flight:** +1. ✓ `Queue` API at `src/queue/mod.rs:134-158` (sync methods). +2. ✓ `McpDescriptorResponse` exists at `src/mcp/descriptor.rs:14`. +3. ✓ `mcp_sessions: Arc>` — `.await` correct. +4. ⚠ Docs URL for `OpenMcpDocs` — placeholder used (`https://operator.untra.io/mcp/`); confirm before merging. +5. ⚠ VS Code MCP shape — verify against current extension behaviour in Task 8 Step 0. +6. ⚠ Dashboard's holding of `ApiState` — Task 9 Step 5 grep verifies; fallback to 0 sessions if not available. + +**Tradeoffs locked in:** +- HTTP and stdio MCP share the handler core. HTTP behavior is unchanged unless `config.mcp.http_enabled = false` (then routes are not mounted at startup; toggle requires restart). +- Ticket write tools are off by default. Users opt in via `[mcp].expose_ticket_write_tools = true`. +- The descriptor extension is additive (`Option`, `skip_serializing_if`) so existing vscode-extension code keeps working until Phase 2 chooses to use the new field. +- Snippet delivery is "write to file + open in editor," reusing `EditFile` — no clipboard dependency. +- Stdio resource subscription is `listChanged: false` — clients re-list rather than subscribe. Simpler; revisit if a real client demands push. diff --git a/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md new file mode 100644 index 0000000..235944a --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md @@ -0,0 +1,426 @@ +# Operator Licensing Platform — Template Bootstrap Plan + +## Context + +Operator is a Rust TUI that the author (Sam / `untra`) wants to license and sell. Beyond Operator itself, the author intends to build other billable software products under the `untra` umbrella. The licensing/billing infrastructure must therefore be **reusable across future untra products**, not bespoke to Operator. + +Two concerns were brainstormed in this session: + +- **X. Operator's licensing & billing system** — sub-projects A (entitlement model) through F (admin tool). Roadmapped here; detailed designs deferred to follow-up sessions. +- **Y. Untra platform template & deployment skeleton** — the reusable cloud + DNS + auth + storefront skeleton that every untra product plugs into. **This is the focus of this session.** Y was promoted from a child of X to a top-level peer once the templating goal was made explicit. + +Goal of this session: produce a plan for creating six template repositories at `../templates/` — one per archetype. Each template gets a `README.md` and a `HANDOFF.md` and nothing else. A top-level `../templates/README.md` indexes the six archetypes for anyone landing in the directory. The templates are then fleshed out in independent follow-up Claude sessions, one per template, using the handoff briefs. + +Cost-deferral is a hard requirement: **no paid commitments should be necessary to complete Phase 0–1**. The first paid commitment is the root-domain registration (~$12/yr), happening at Phase 2. + +--- + +## Reading of the request + +This plan rests on one interpretation of the user's template list (`iac api app auth admin license`). Surfacing it loudly so it can be corrected at review time: + +- **`api`** — generic product-side backend. Cloud Run service in the public per-project monorepo. Exposes (1) an **unauthenticated** `/version` endpoint and (2) **authenticated** endpoints (verified against the entitlement JWT signed by `license`) that report user details and serve product business logic. Receives the **LemonSqueezy purchase webhook** and, on a successful sale, calls `license` over a signed internal request to mint a license record. +- **`license`** — privileged entitlement microservice. Lives in the **private** sister repo (`operator-private` for Operator; `-private` for other untra products). Holds the Ed25519 signing key in Secret Manager. Only `auth` and `api` may call it. Verifies a posted license key, returns/issues a signed entitlement JWT (booleans + integer limits, ~14-day TTL). Maintains the revocation list. +- **`auth`** — identity orchestrator at `auth.`. Wraps Firebase Auth (sign-in UI). After the user signs in, `auth` accepts a license key, calls `license` to verify it, and packages the resulting entitlements into a JWT (signed by `license`) returned to the client. Acts as the trust bridge between Firebase identity and license entitlements. + +If this reading is wrong, sections below collapse. Push back at spec review. + +--- + +## Roadmap + +### X. Operator licensing sub-projects (decomposition only) + +| # | Component | Lives in | Status | +|---|---|---|---| +| A | Entitlement model & license-key format | shared schema crate / proto | **Deferred** — detailed spec in a follow-up session. Eight decisions already locked (see "Entitlement model — locked decisions" below). | +| B | License verification service | `operator-private`, generated from `templates/license` | Built via Y. Detailed implementation in a follow-up session. | +| C | Operator client integration | this repo (`operator/`) | Future ticket. Reads entitlement JWT, exposes flags via OpenFeature provider. | +| D | Storefront + billing | LemonSqueezy hosted; no template. Adapters in `templates/app` + `templates/api`. | Built via Y. | +| E | Customer account site | `templates/app` instance | Built via Y. | +| F | Admin tool | `templates/admin` instance | Built via Y. | + +### Y. Untra platform template (this session) + +Six archetype template repos at `../templates/`: + +``` +templates/ +├── iac/ # Terraform/OpenTofu modules: Cloudflare, GCP, Firebase, Neon, IAP, Secret Manager +├── api/ # Generic product backend (Rust + Axum + Cloud Run) +├── app/ # Customer-facing web app (TypeScript SPA, sign-in via Firebase, license mgmt UI) +├── auth/ # Identity orchestrator (auth.): Firebase Auth UI + license-exchange endpoint +├── admin/ # Admin console (IAP-gated): plan editor, license mgmt, revocation +└── license/ # Privileged entitlement service (vendors into *-private repos only) +``` + +### Entitlement model — locked decisions (referenced by `license`) + +These were agreed earlier in the session. They become the "Entitlement Model — frozen" section in `license/README.md`. Detailed token schema/crypto is part of the deferred A-spec. + +1. **Vintage model:** Adobe-style year-versioned major releases (`standard-2025`, `enterprise-2026`). Each vintage is a distinct SKU. +2. **Access duration:** Perpetual one-time buy per vintage. No subscription expiry. +3. **Feature types:** Booleans + integer limits (e.g. `acp_enabled=true`, `max_projects=20`). +4. **Verification model:** Hybrid — short opaque license key + server-issued signed entitlement JWT cached for ~14 days. +5. **License scope:** Single user, soft cap of 3 machines per license. +6. **Account model:** One account owns many licenses over time. +7. **OpenFeature shape:** Custom OpenFeature provider in client (Rust SDK) reads flags from cached entitlement JWT. +8. **Revocation:** Revocation list + TTL-driven propagation. Already-cached tokens expire within ~14 days of revocation. + +--- + +## Service trust model + +``` + Firebase ID token + user ──signin──▶ auth. ──┐ + │ (verify license key + Firebase identity) + ▼ + license. + (Ed25519 sign entitlement JWT) + │ + client ◀───── entitlement JWT ───┘ + │ + ▼ Bearer JWT + api. ◀── verifies JWT against license public key (embedded) + │ + └── LemonSqueezy webhook ──▶ signed internal call ──▶ license (mint license) +``` + +**Trust boundary:** `license`'s signing key never leaves the private GCP project. `auth` and `api` only hold `license`'s public key. Public-monorepo CI cannot touch `license`'s secrets. + +--- + +## Architectural decisions (named, not buried) + +### D1. Two-repo structure per product (public + private) + +Every untra product produces **two** GitHub repos at provisioning time: + +- `/` — public monorepo, vendors `iac` + `api` + `app` + `auth` + `admin`. +- `-private/` — private monorepo, vendors `license` and a separate slice of `iac` (the private-side state, signing-key Secret Manager, separate Neon project). + +The two repos correspond to two separate GCP projects with no cross-project IAM. The only runtime coupling is the signed internal HTTPS call from `api` (public) to `license` (private). + +### D2. LemonSqueezy webhook lands in `api`, not `license` + +Webhooks have public, unauthenticated ingress by design. `api` already has a public surface and a Neon DB for user records — it's the right place to receive them. `api` then makes a **signed internal request** (HMAC over webhook payload + nonce) to `license` to mint the actual license. `license` never receives unauthenticated external traffic; the only external endpoints on `license` are token-signing endpoints called by `auth`. + +### D3. The first untra product (Operator) eats its own dog food + +Operator is both: +- a **consumer** of the platform (it has license keys, calls `auth` to refresh entitlements, gates features via OpenFeature) +- the **bootstrap operator** for future products (per its CLAUDE.md, "self-starting work multiplexor") + +Therefore Operator's licensing integration (sub-project C) and the platform templates (Y) must be designed so Operator can later orchestrate Phase 2 deployments for *new* untra products. This session does not implement that orchestration; it only avoids painting it into a corner. + +--- + +## Cost-deferral phases + +- **Phase 0** *(this session, $0)*: six templates exist at `../templates//`, each `git init`'d, each containing only `README.md` and `HANDOFF.md`. +- **Phase 1** *(follow-up Claude sessions, $0)*: each template gets fleshed out by a fresh Claude session using its HANDOFF.md. Output: working code, Dockerfiles, IaC modules. Still local; no cloud accounts needed. +- **Phase 2** *(first deployment, ~$12/yr)*: register Operator's root domain. Set up Cloudflare zone (free), GCP project (uses $300 trial credit), Firebase Auth (free tier), Neon Postgres (free tier), Google Secret Manager (free tier). Deploy all services to Cloud Run with `min_instances=0`. Cost ceiling: ~$12/yr for the year, possibly $0 within trial credit. +- **Phase 3** *(first sale)*: LemonSqueezy onboarding (~30 min, no business entity required). First transaction triggers the first revenue and the first 5%+50¢ fee. No upfront commitment. + +--- + +## What this session WILL produce (post-ExitPlanMode) + +**Top-level index file:** +1. Write `../templates/README.md` — a one-page index explaining the six archetype repos, the platform stack, and how they fit together. Section outline below. + +**Per archetype** (`iac`, `api`, `app`, `auth`, `admin`, `license`): +1. Create directory `../templates//`. +2. Run `git init` inside it. +3. Write `README.md` per the outline below. +4. Write `HANDOFF.md` per the outline below. +5. **Do not commit.** Per user instruction (memory: `feedback_no_commits.md`), the user handles all commits. + +**Promotion step:** +1. After the templates exist, copy this plan file to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` so it survives outside `~/.claude/plans/`. (User confirmed promotion at plan approval.) + +That is the entirety of the action. **No source files, no Dockerfiles, no Terraform.** Those are Phase 1, in separate sessions. + +--- + +## Top-level `../templates/README.md` outline + +A short index file at the root of the templates directory. Anyone who `cd`s into `../templates/` should understand the platform in under a minute. Sections in order: + +``` +# untra platform templates + +## What this directory is +One paragraph: these are archetype templates for untra's billable-SaaS platform. +Each subdirectory is its own git repo; they get vendored into per-product +monorepos at Phase 2. + +## The six archetypes +A table: archetype name | one-line role | vendors into (public/private). + +## Platform stack (defaults) +The cost-deferral stack table from the master spec, abbreviated: +domain (Cloudflare), compute (Cloud Run min=0), identity (Firebase Auth), +data (Neon Postgres), storefront (LemonSqueezy MoR), CI (GitHub Actions), +IaC (OpenTofu). Cost-at-zero-traffic ceiling: ~$12/yr (domain only). + +## Trust model +The auth → license → JWT → api diagram, in ASCII. + +## How a new product is provisioned +Cross-reference templates/iac/README.md "Quickstart" section for the manual +checklist. + +## Status +"Phase 0: READMEs and handoff briefs only. See each subdirectory's HANDOFF.md +to start implementation in a fresh Claude session." + +## Master spec +Pointer to operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md +``` + +--- + +## README.md outline (per template) + +A common shape, plus per-archetype detail. Each `README.md` should contain these sections in this order: + +``` +# — untra platform template + +## Purpose +One paragraph describing what an instance of this template does in a deployed untra product. + +## Role in the platform +A short list of which other archetypes this template talks to and how. + +## Tech stack +Language, framework, deployment target. From the locked Y stack. + +## Repository layout +The directory shape this template prescribes. + +## Quickstart (instantiation) +How a Phase 2 operator clones this template into a new product monorepo. + +## Configuration +Parameters that must be set per product (e.g. `{root_domain}`, `{gcp_project_id}`). + +## Cost profile +Free-tier story; what triggers paid usage. + +## Status +"Phase 0: README and handoff brief only. See HANDOFF.md to start implementation." + +## Links +Pointer to master spec, the platform stack table, related archetypes. +``` + +### Per-archetype README key facts + +**`templates/iac/README.md`:** +- Purpose: Terraform/OpenTofu modules that provision a single untra product's cloud (public + private sides). +- Modules to enumerate: `cloudflare_zone`, `gcp_project`, `cloud_run_service`, `firebase_auth`, `neon_project`, `secret_manager`, `iap_admin`, `github_oidc_wif`. +- Two top-level compositions: `iac/public/` and `iac/private/`, run against separate GCP projects. +- Backend state: GCS bucket per product, configured via `terraform init -backend-config`. + +**`templates/api/README.md`:** +- Purpose: generic product backend. Rust + Axum + Cloud Run. +- Endpoints (initial): `GET /version` (unauth), `GET /me` (entitlement-JWT-auth, returns user + active license summary), `POST /webhooks/lemonsqueezy` (HMAC-verified). +- Verifies entitlement JWT against `license` public key, embedded at build time. +- Talks to: Neon Postgres (user table, license cache table); calls `license` over signed HMAC internal request. + +**`templates/app/README.md`:** +- Purpose: customer-facing web app at `app.`. TypeScript + React + Vite + Firebase JS SDK. +- Routes (initial): `/` (landing/download), `/signin` (redirects to `auth.`), `/account` (signed-in: shows licenses, machines), `/licenses/:id` (manage machines for a license). +- Calls `auth.` for sign-in flow; calls `api.` for authenticated data. +- Static-friendly: deployable to Cloud Storage + Cloud CDN or Cloudflare Pages. + +**`templates/auth/README.md`:** +- Purpose: identity orchestrator at `auth.`. Cloud Run service + small static UI. +- Flow: user signs in via Firebase (email/password, magic-link, OAuth) → static UI captures Firebase ID token → `auth` calls `license.` to verify entitlement → returns entitlement JWT to client. Also handles license-key claim (first-time activation) and machine registration (machine-fingerprint binding within the 3-machine cap). +- Endpoints: `POST /claim` (Firebase token + license key + machine fingerprint), `POST /refresh` (Firebase token + machine fingerprint). + +**`templates/admin/README.md`:** +- Purpose: admin console at `admin.`, gated by GCP IAP (no Firebase Auth — admin is internal). +- Routes (initial): `/plans` (define plans + feature bundles via OpenFeature schema), `/licenses` (search, view, revoke), `/users` (view accounts), `/billing` (LemonSqueezy passthrough links). +- Calls `api` for read data, calls `license` for write actions (mint license manually, revoke). + +**`templates/license/README.md`:** +- Purpose: privileged entitlement service. Rust + Axum + Cloud Run. Lives in private sister repo only. +- **Embed the eight locked entitlement decisions verbatim** (see "Entitlement model — locked decisions" above) as a "Frozen entitlement model" section. +- Endpoints: `POST /internal/verify` (called by `auth`, HMAC-authed, returns entitlement JWT), `POST /internal/mint` (called by `api`, HMAC-authed, creates a license record), `POST /internal/revoke` (called by `admin`, HMAC-authed). All endpoints are internal-only (Cloud Run with IAM-based ingress restriction). +- Crypto: Ed25519 signing key in Secret Manager; public key available at a public, unauth, cacheable `GET /.well-known/license-public-key` endpoint (used by client-side OpenFeature provider and by `api` for JWT verification). +- Detailed token schema and key-rotation policy: **deferred to A-spec follow-up.** + +--- + +## HANDOFF.md outline (per template) + +Fixed structure across all six templates so the briefs are interchangeable. Each `HANDOFF.md` should contain these sections in this order: + +``` +# Handoff brief — + +> Read this file before starting implementation. You are a fresh Claude session +> with no prior context. The README.md in this same directory has the role and +> stack. This file tells you what "done" looks like for the first milestone. + +## Pointer to master spec +Path: ~/.claude/plans/this-project-operator-is-rustling-tome.md +(or wherever the user has moved it after approval — check first). + +## Role (one paragraph) +Restate the role from README. Confirms shared interpretation. + +## Acceptance criteria (testable) +Concrete, runnable checks. Examples: +- "Produces a Cloud Run service that responds HTTP 200 to /healthz" +- "Returns HTTP 401 to /me when no Authorization header is present" +- "Terraform plan against a clean GCP project produces zero errors" + +## Public interface +- HTTP endpoints / UI routes / Terraform variables / library exports. +- Schemas where they exist (link to A-spec for entitlement JWT schema). + +## Dependencies +- Which other archetypes this calls (by name). +- Which other archetypes call this (by name). +- External services (Firebase, Neon, Cloudflare, LemonSqueezy). + +## Non-goals +Explicit list of things NOT to build in this session. Examples for `api`: +- Do not implement license signing — that's `license`'s job. +- Do not build the admin endpoints — those live in `admin`. +- Do not add OpenTelemetry exporters yet; add `tracing` only. + +## First milestone (smallest deployable slice) +A specific, minimal end-to-end slice that proves the template works. +Example for `api`: "GET /version returns {\"version\":\"0.1.0\"} as JSON, +deployed to Cloud Run min=0, served at api.." + +## Out-of-scope flags for later milestones +List of features to leave as `// TODO(milestone-2)` comments so the next +session knows what comes next. +``` + +### Per-archetype HANDOFF key facts + +**`templates/iac/HANDOFF.md`:** +- First milestone: a `terraform plan` for a hypothetical product `example-product` against a fresh GCP project produces a valid plan (no apply required this milestone). +- Non-goals: do not write a GitHub Actions workflow that runs `terraform apply` yet; do not provision DNS records for the private domain (private side is its own composition). + +**`templates/api/HANDOFF.md`:** +- First milestone: `GET /version` returns the current version as JSON; service builds into a Cloud Run image via the included Dockerfile; `curl localhost:8080/version` works locally. +- Non-goals: do not implement webhook signature verification yet (stub it); do not implement `/me` yet; do not connect to Neon yet (stub the DB layer). + +**`templates/app/HANDOFF.md`:** +- First milestone: a static site that renders a landing page with a "Download Operator" button and a "Sign In" link to `auth./signin`; `npm run build` produces a deployable `dist/`. +- Non-goals: no `/account` page yet; no API integration; no Firebase wiring in JS yet (just a link). + +**`templates/auth/HANDOFF.md`:** +- First milestone: a Cloud Run service exposing a static `/signin` page (Firebase UI or hand-rolled email-link form) that successfully signs a user in and shows their Firebase UID; `POST /claim` is stubbed and returns `501 Not Implemented`. +- Non-goals: do not call `license` yet (stub the call); do not implement machine-fingerprint logic yet; do not implement `/refresh`. + +**`templates/admin/HANDOFF.md`:** +- First milestone: a Cloud Run service with IAP enforcement that renders a "Hello, {user.email}" page sourced from `X-Goog-Authenticated-User-Email`. +- Non-goals: no plan editor yet; no license search; no revocation UI; no API integration. + +**`templates/license/HANDOFF.md`:** +- First milestone: a Cloud Run service with a `/healthz` endpoint and a `/.well-known/license-public-key` endpoint that returns a hardcoded Ed25519 public key (real key generation deferred to A-spec). +- Non-goals: do not implement `/internal/verify`, `/internal/mint`, or `/internal/revoke` yet; do not implement the revocation list; do not implement HMAC validation of internal callers yet (return `501` with a `TODO` comment); do not freeze the entitlement JWT schema (waits on A-spec follow-up). + +--- + +## Critical files to be created + +``` +../templates/README.md (top-level index, not in any git repo) +../templates/iac/README.md +../templates/iac/HANDOFF.md +../templates/iac/.git/ (git init only) +../templates/api/README.md +../templates/api/HANDOFF.md +../templates/api/.git/ +../templates/app/README.md +../templates/app/HANDOFF.md +../templates/app/.git/ +../templates/auth/README.md +../templates/auth/HANDOFF.md +../templates/auth/.git/ +../templates/admin/README.md +../templates/admin/HANDOFF.md +../templates/admin/.git/ +../templates/license/README.md +../templates/license/HANDOFF.md +../templates/license/.git/ +``` + +Plus the promotion copy: + +``` +operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md +``` + +Total: 13 files written, 6 directories `git init`'d, 1 spec file promoted. **Zero commits.** + +--- + +## Verification + +After implementation: + +```bash +# 0. Top-level index exists. +test -f ../templates/README.md && echo "top README OK" || echo "TOP README MISSING" + +# 1. All directories exist and are git repos. +for t in iac api app auth admin license; do + test -d ../templates/$t/.git && echo "$t: git OK" || echo "$t: GIT MISSING" +done + +# 2. Both files exist per template. +for t in iac api app auth admin license; do + test -f ../templates/$t/README.md && test -f ../templates/$t/HANDOFF.md \ + && echo "$t: files OK" || echo "$t: files MISSING" +done + +# 3. No stray files inside template repos. +find ../templates -mindepth 2 -maxdepth 2 -type f \ + ! -name README.md ! -name HANDOFF.md ! -path '*/.git/*' \ + | grep -v "^$" && echo "stray files present" || echo "no stray files" + +# 4. No commits yet (user commits manually). +for t in iac api app auth admin license; do + ( cd ../templates/$t && test -z "$(git log 2>/dev/null)" \ + && echo "$t: no commits OK" || echo "$t: HAS COMMITS" ) +done + +# 5. Promoted spec exists. +test -f operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md \ + && echo "promoted spec OK" || echo "PROMOTED SPEC MISSING" +``` + +After Phase 1 (separate sessions, out of scope here): each template implements its first-milestone acceptance criteria. + +--- + +## Non-goals of this plan (explicit) + +- No code beyond `README.md` and `HANDOFF.md`. +- No commits anywhere. +- No registration of any cloud account, domain, or LemonSqueezy account. +- No detailed entitlement-JWT schema or key rotation policy (that's A-spec follow-up). +- No work in `operator/` itself in this session (Operator's licensing-client integration C is a future ticket). +- No GitHub Actions workflows, Dockerfiles, or Terraform code. +- No bootstrap script (user chose "manual checklist" — checklist content lives in `templates/iac/README.md` Quickstart section, not as separate code). + +--- + +## Follow-ups queued after this plan executes + +1. **A-spec brainstorm session** — flesh out the entitlement-JWT schema, key format, key rotation, machine-fingerprint algorithm. Produces the detailed `license` data model. +2. **Per-template implementation sessions (6 of them)** — each starts a fresh Claude in the relevant `../templates//` directory and works from HANDOFF.md. +3. **Operator integration (C)** — separate ticket in `operator/` to add the OpenFeature provider, license-key UI, refresh loop. Depends on A-spec being done. +4. **Promote this plan** — confirmed at approval. Copy to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` as part of this session's deliverable (user commits manually). diff --git a/docs/backstage/taxonomy.md b/docs/taxonomy/index.md similarity index 93% rename from docs/backstage/taxonomy.md rename to docs/taxonomy/index.md index d1b8e89..b521b5d 100644 --- a/docs/backstage/taxonomy.md +++ b/docs/taxonomy/index.md @@ -3,25 +3,25 @@ title: "Project Taxonomy" layout: doc --- - + # Project Taxonomy This document defines the **25 project Kinds** organized into **5 tiers**. -Each Kind represents a category of project that can be cataloged in Backstage. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files. +Each Kind represents a category of project that can be classified by Operator. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files. ## Version - **Version**: `1.0.0` -- **Description**: Operator project taxonomy for Backstage catalog +- **Description**: Operator project taxonomy for project classification ## Quick Reference All 24 Kinds at a glance: -| ID | Key | Name | Tier | Backstage Type | +| ID | Key | Name | Tier | Catalog Type | | --- | --- | --- | --- | --- | | 1 | `infrastructure` | Infrastructure (IaC) | foundation | `resource` | | 2 | `identity-access` | Identity & Access (IAM) | foundation | `resource` | @@ -67,7 +67,7 @@ Cloud resources and network (Terraform/CDK) - **Key**: `infrastructure` - **Stakeholder**: Platform/DevOps - **Primary Output**: Cloud Environment -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `*.tf` @@ -88,7 +88,7 @@ Service accounts, secrets, and RBAC policies - **Key**: `identity-access` - **Stakeholder**: SDET/SecOps - **Primary Output**: Permissions/Tokens -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `iam-*.yaml` @@ -108,7 +108,7 @@ Global feature flags and environment manifests - **Key**: `config-policy` - **Stakeholder**: Platform/DevOps - **Primary Output**: Runtime Behavior -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `config/*.yaml` @@ -128,7 +128,7 @@ Orchestration for projects and root standards - **Key**: `monorepo-meta` - **Stakeholder**: Architect/Lead - **Primary Output**: Project Standards -- **Backstage Type**: `system` +- **Catalog Type**: `system` **Detection** File Patterns: - `nx.json` @@ -161,7 +161,7 @@ Reusable UI components and brand tokens - **Key**: `design-system` - **Stakeholder**: Product/UX - **Primary Output**: Component Libraries -- **Backstage Type**: `library` +- **Catalog Type**: `library` **Detection** File Patterns: - `tokens/*.json` @@ -180,7 +180,7 @@ Reusable internal logic packages (Shared Utils) - **Key**: `software-library` - **Stakeholder**: Engineering - **Primary Output**: Versioned Packages -- **Backstage Type**: `library` +- **Catalog Type**: `library` **Detection** File Patterns: - `lib/*` @@ -199,7 +199,7 @@ API contracts and generated client libraries - **Key**: `proto-sdk` - **Stakeholder**: Engineering - **Primary Output**: Contract Libraries -- **Backstage Type**: `api` +- **Catalog Type**: `api` **Detection** File Patterns: - `*.proto` @@ -222,7 +222,7 @@ Scaffolding templates for bootstrapping repos - **Key**: `blueprint` - **Stakeholder**: Architect/Lead - **Primary Output**: Project Templates -- **Backstage Type**: `template` +- **Catalog Type**: `template` **Detection** File Patterns: - `template.yaml` @@ -241,7 +241,7 @@ Custom scanners, audit scripts, and honeytokens - **Key**: `security-tooling` - **Stakeholder**: SDET/SecOps - **Primary Output**: Security Reports -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `security/*` @@ -262,7 +262,7 @@ Evidence, snapshots, and regulatory reports - **Key**: `compliance-audit` - **Stakeholder**: SDET/SecOps - **Primary Output**: Compliance Proofs -- **Backstage Type**: `documentation` +- **Catalog Type**: `documentation` **Detection** File Patterns: - `compliance/*` @@ -295,7 +295,7 @@ Training scripts and model weight artifacts - **Key**: `ml-model` - **Stakeholder**: Data/ML - **Primary Output**: Model Artifacts -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `model/*` @@ -318,7 +318,7 @@ Data transformation logic and SQL models - **Key**: `data-etl` - **Stakeholder**: Data/ML - **Primary Output**: Clean Datasets -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `dbt_project.yml` @@ -338,7 +338,7 @@ Backend business logic and domain units - **Key**: `microservice` - **Stakeholder**: Engineering - **Primary Output**: Running Binaries -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `src/main.rs` @@ -359,7 +359,7 @@ Entry points that route and protect traffic - **Key**: `api-gateway` - **Stakeholder**: Engineering - **Primary Output**: Network Endpoints -- **Backstage Type**: `api` +- **Catalog Type**: `api` **Detection** File Patterns: - `gateway/*` @@ -380,7 +380,7 @@ Web or mobile apps for end-user interaction - **Key**: `ui-frontend` - **Stakeholder**: Engineering - **Primary Output**: Web/Mobile Assets -- **Backstage Type**: `website` +- **Catalog Type**: `website` **Detection** File Patterns: - `src/App.tsx` @@ -402,7 +402,7 @@ Private apps for internal business operations - **Key**: `internal-tool` - **Stakeholder**: Engineering - **Primary Output**: Operational Apps -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `admin/*` @@ -431,7 +431,7 @@ CI/CD actions and custom build logic - **Key**: `build-tool` - **Stakeholder**: Platform/DevOps - **Primary Output**: Automated Pipelines -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `.github/workflows/*` @@ -452,7 +452,7 @@ Integration tests and smoke test runners - **Key**: `e2e-test` - **Stakeholder**: SDET/SecOps - **Primary Output**: Quality Reports -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `e2e/*` @@ -473,7 +473,7 @@ Documentation, tutorials, and references - **Key**: `docs-site` - **Stakeholder**: Product/UX - **Primary Output**: Static Support Sites -- **Backstage Type**: `website` +- **Catalog Type**: `website` **Detection** File Patterns: - `docs/*` @@ -494,7 +494,7 @@ Incident response and on-call runbooks - **Key**: `playbook` - **Stakeholder**: Platform/DevOps - **Primary Output**: Operational Guides -- **Backstage Type**: `documentation` +- **Catalog Type**: `documentation` **Detection** File Patterns: - `playbooks/*` @@ -512,7 +512,7 @@ Productivity scripts and developer utilities - **Key**: `cli-devtool` - **Stakeholder**: Platform/DevOps - **Primary Output**: Developer UX Tools -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `cli/*` @@ -541,7 +541,7 @@ Best-practice implementation examples - **Key**: `reference-example` - **Stakeholder**: Architect/Lead - **Primary Output**: Educational Code -- **Backstage Type**: `documentation` +- **Catalog Type**: `documentation` **Detection** File Patterns: - `examples/*` @@ -558,7 +558,7 @@ Proof-of-concepts and R&D "spikes" - **Key**: `experiment-sandbox` - **Stakeholder**: Engineering - **Primary Output**: Discardable Code -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `experiments/*` @@ -576,7 +576,7 @@ Legacy code and forks of 3rd party repos - **Key**: `archival-fork` - **Stakeholder**: SDET/SecOps - **Primary Output**: Historical/Vendor Code -- **Backstage Type**: `library` +- **Catalog Type**: `library` **Detection** File Patterns: - `vendor/*` @@ -594,7 +594,7 @@ Repositories containing test data, fixtures, seed data, and mock datasets - **Key**: `test-data-fixtures` - **Stakeholder**: SDET/SecOps - **Primary Output**: Test Data Assets -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `fixtures/*` @@ -901,11 +901,11 @@ Patterns use glob syntax: - `*.seed.sql` - `db/seeds/*` -## Backstage Type Mapping +## Catalog Type Mapping -Each Kind maps to a Backstage catalog type: +Each Kind maps to a catalog type: -| Backstage Type | Kinds | +| Catalog Type | Kinds | | --- | --- | | `api` | `proto-sdk`, `api-gateway` | | `documentation` | `compliance-audit`, `playbook`, `reference-example` | diff --git a/icons/coder.svg b/icons/coder.svg new file mode 100644 index 0000000..a0acff3 --- /dev/null +++ b/icons/coder.svg @@ -0,0 +1 @@ +Coder \ No newline at end of file diff --git a/icons/zedindustries.svg b/icons/zedindustries.svg new file mode 100644 index 0000000..02327fd --- /dev/null +++ b/icons/zedindustries.svg @@ -0,0 +1 @@ +Zed Industries \ No newline at end of file diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index afb58bb..e6b687b 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -19,15 +19,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -95,9 +95,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -107,9 +107,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.52" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -129,9 +129,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.54" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -151,9 +151,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -163,15 +163,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "dirs" @@ -223,26 +223,25 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "foldhash" @@ -270,35 +269,35 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-task", "pin-project-lite", - "pin-utils", + "slab", ] [[package]] @@ -352,9 +351,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -364,9 +363,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -403,9 +402,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -416,7 +415,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -424,15 +422,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -441,14 +438,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -465,12 +461,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -478,9 +475,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -491,9 +488,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -505,15 +502,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -525,15 +522,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -563,9 +560,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -578,7 +575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -614,19 +611,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -636,16 +623,18 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -662,9 +651,9 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ "bitflags 2.11.1", "libc", @@ -678,9 +667,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" @@ -688,29 +677,26 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru-slab" @@ -720,15 +706,15 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -766,9 +752,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -793,7 +779,7 @@ dependencies = [ [[package]] name = "opr8r" -version = "0.1.31" +version = "0.2.0" dependencies = [ "clap", "operator-relay", @@ -819,27 +805,15 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "plain" -version = "0.2.3" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -865,9 +839,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -886,7 +860,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -894,9 +868,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -907,7 +881,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -929,9 +903,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -950,9 +924,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -970,22 +944,13 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -1051,15 +1016,15 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.1", "errno", @@ -1070,9 +1035,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -1084,9 +1049,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1111,9 +1076,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -1162,9 +1127,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1203,9 +1168,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -1215,12 +1180,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1243,9 +1208,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1274,12 +1239,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1296,11 +1261,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -1316,9 +1281,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1327,9 +1292,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1337,9 +1302,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -1352,9 +1317,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -1368,9 +1333,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1404,20 +1369,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -1471,9 +1436,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-xid" @@ -1549,11 +1514,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.46.0", + "wit-bindgen 0.57.1", ] [[package]] @@ -1567,9 +1532,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -1580,22 +1545,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1603,9 +1565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -1616,9 +1578,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -1659,9 +1621,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -1679,9 +1641,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -1923,12 +1885,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1938,6 +1894,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -2019,15 +1981,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2036,9 +1998,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2048,18 +2010,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2068,18 +2030,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2095,9 +2057,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2106,9 +2068,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2117,9 +2079,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -2128,6 +2090,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.13" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index 8ef3ad5..e096a9b 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opr8r" -version = "0.1.31" +version = "0.2.0" edition = "2021" description = "Minimal CLI wrapper for LLM commands in multi-step ticket workflows" license = "MIT" diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh new file mode 100755 index 0000000..76d8581 --- /dev/null +++ b/scripts/cicdprep.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +# --- Colors & helpers --- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +RUN_ALL=false +CONTINUE_ON_FAIL=false +FAILURES=() +PASSES=() +SKIPPED=() + +usage() { + echo "Usage: $(basename "$0") [OPTIONS]" + echo "" + echo "Run CI/CD checks locally before creating a PR." + echo "Auto-detects changed files and runs only relevant workflow checks." + echo "" + echo "Options:" + echo " --all Run all checks regardless of changed files" + echo " --continue Don't stop on first failure; run everything and report" + echo " -h, --help Show this help" +} + +for arg in "$@"; do + case "$arg" in + --all) RUN_ALL=true ;; + --continue) CONTINUE_ON_FAIL=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $arg"; usage; exit 1 ;; + esac +done + +section() { + echo "" + echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════${RESET}" + echo -e "${CYAN}${BOLD} $1${RESET}" + echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════${RESET}" +} + +step() { + echo -e "\n${BOLD}▸ $1${RESET}" +} + +pass() { + echo -e " ${GREEN}✓ $1${RESET}" + PASSES+=("$1") +} + +fail() { + echo -e " ${RED}✗ $1${RESET}" + FAILURES+=("$1") + if [ "$CONTINUE_ON_FAIL" = false ]; then + echo -e "\n${RED}${BOLD}FAILED: $1${RESET}" + echo -e "${RED}Use --continue to run all checks despite failures.${RESET}" + exit 1 + fi +} + +skip() { + echo -e " ${YELLOW}⊘ $1 (skipped — no changes)${RESET}" + SKIPPED+=("$1") +} + +require_tool() { + local tool="$1" + local context="$2" + if ! command -v "$tool" &>/dev/null; then + echo -e "${RED}Missing required tool: ${BOLD}$tool${RESET}${RED} (needed for $context)${RESET}" + echo "Install it and re-run." + exit 1 + fi +} + +run_step() { + local label="$1" + shift + step "$label" + if "$@"; then + pass "$label" + else + fail "$label" + fi +} + +# --- Detect changed files --- + +section "Detecting changes" + +MAIN_BRANCH="main" +if ! git rev-parse --verify "$MAIN_BRANCH" &>/dev/null; then + MAIN_BRANCH="origin/main" +fi + +MERGE_BASE=$(git merge-base "$MAIN_BRANCH" HEAD 2>/dev/null || echo "") + +if [ -z "$MERGE_BASE" ]; then + echo -e "${YELLOW}Could not find merge base with $MAIN_BRANCH — running all checks.${RESET}" + RUN_ALL=true + CHANGED_FILES="" +else + CHANGED_FILES=$(git diff --name-only "$MERGE_BASE"...HEAD 2>/dev/null || "") + UNSTAGED=$(git diff --name-only 2>/dev/null || "") + STAGED=$(git diff --name-only --cached 2>/dev/null || "") + CHANGED_FILES=$(echo -e "${CHANGED_FILES}\n${UNSTAGED}\n${STAGED}" | sort -u | grep -v '^$' || true) +fi + +if [ "$RUN_ALL" = true ]; then + echo -e "${YELLOW}Running ALL checks (--all or no merge base).${RESET}" +else + FILE_COUNT=$(echo "$CHANGED_FILES" | grep -c '.' || echo 0) + echo -e "Found ${BOLD}$FILE_COUNT${RESET} changed file(s) vs $MAIN_BRANCH." + if [ "$FILE_COUNT" -eq 0 ]; then + echo -e "${GREEN}No changes detected. Nothing to check.${RESET}" + exit 0 + fi +fi + +has_changes() { + local pattern="$1" + if [ "$RUN_ALL" = true ]; then + return 0 + fi + echo "$CHANGED_FILES" | grep -qE "$pattern" +} + +# build.yaml triggers on everything EXCEPT docs-only or version-only changes +needs_operator() { + if [ "$RUN_ALL" = true ]; then return 0; fi + local non_ignored + non_ignored=$(echo "$CHANGED_FILES" | grep -vE '^(docs/|\.github/workflows/docs\.yml$|VERSION$)' || true) + [ -n "$non_ignored" ] +} + +needs_opr8r() { has_changes '^opr8r/'; } +needs_vscode() { has_changes '^(vscode-extension/|icons/)'; } +needs_zed() { has_changes '^zed-extension/'; } +needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json)'; } + +# --- 1. Operator (main crate) --- + +if needs_operator; then + section "Operator (main crate)" + require_tool cargo "operator" + require_tool bun "operator UI build" + require_tool cargo-deny "operator dependency audit" + + step "UI build" + ( + cd ui + bun install --frozen-lockfile + bun run build + DIST_SIZE=$(du -sk dist/ | awk '{print $1 * 1024}') + echo " UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB)" + if [ "$DIST_SIZE" -gt 5242880 ]; then + echo "UI dist exceeds 5MB budget (${DIST_SIZE}B)" >&2 + exit 1 + fi + ) && pass "UI build + size check" || fail "UI build + size check" + + run_step "cargo fmt" cargo fmt -- --check + run_step "cargo clippy" cargo clippy --locked --all-targets --all-features -- -D warnings + run_step "cargo test" cargo test --locked --all-features + run_step "cargo deny" cargo deny --manifest-path Cargo.toml check +else + skip "Operator (main crate)" +fi + +# --- 2. opr8r --- + +if needs_opr8r; then + section "opr8r" + require_tool cargo "opr8r" + require_tool cargo-deny "opr8r dependency audit" + + run_step "opr8r fmt" bash -c "cd opr8r && cargo fmt -- --check" + run_step "opr8r clippy" bash -c "cd opr8r && cargo clippy --locked --all-targets --all-features -- -D warnings" + run_step "opr8r test" bash -c "cd opr8r && cargo test --locked --all-features" + run_step "opr8r cargo deny" cargo deny --manifest-path opr8r/Cargo.toml check +else + skip "opr8r" +fi + +# --- 3. vscode-extension --- + +if needs_vscode; then + section "vscode-extension" + require_tool node "vscode-extension" + require_tool npm "vscode-extension" + + step "Install dependencies" + (cd vscode-extension && npm ci) && pass "vscode install" || fail "vscode install" + + run_step "vscode copy-types" bash -c "cd vscode-extension && npm run copy-types" + run_step "vscode generate:icons" bash -c "cd vscode-extension && mkdir -p images/icons/dist && npm run generate:icons" + run_step "vscode lint" bash -c "cd vscode-extension && npm run lint" + run_step "vscode compile" bash -c "cd vscode-extension && npm run compile" + run_step "vscode compile:webview" bash -c "cd vscode-extension && npm run compile:webview" +else + skip "vscode-extension" +fi + +# --- 4. zed-extension --- + +if needs_zed; then + section "zed-extension" + require_tool cargo "zed-extension" + require_tool cargo-deny "zed-extension dependency audit" + + if ! rustup target list --installed 2>/dev/null | grep -q wasm32-wasip1; then + echo -e "${YELLOW}Installing wasm32-wasip1 target...${RESET}" + rustup target add wasm32-wasip1 + fi + + run_step "zed fmt" bash -c "cd zed-extension && cargo fmt -- --check" + run_step "zed clippy" bash -c "cd zed-extension && cargo clippy --locked --target wasm32-wasip1 -- -D warnings" + run_step "zed build" bash -c "cd zed-extension && cargo build --locked --release --target wasm32-wasip1" + run_step "zed cargo deny" cargo deny --manifest-path zed-extension/Cargo.toml check +else + skip "zed-extension" +fi + +# --- 5. docs --- + +if needs_docs; then + section "docs" + require_tool cargo "docs generation" + require_tool bundle "docs Jekyll build" + + run_step "docs generate" cargo run --locked -- docs + step "Jekyll build" + (cd docs && bundle install && bundle exec jekyll build) && pass "Jekyll build" || fail "Jekyll build" +else + skip "docs" +fi + +# --- Summary --- + +section "Summary" + +if [ ${#PASSES[@]} -gt 0 ]; then + echo -e "\n${GREEN}${BOLD}Passed (${#PASSES[@]}):${RESET}" + for p in "${PASSES[@]}"; do + echo -e " ${GREEN}✓${RESET} $p" + done +fi + +if [ ${#SKIPPED[@]} -gt 0 ]; then + echo -e "\n${YELLOW}${BOLD}Skipped (${#SKIPPED[@]}):${RESET}" + for s in "${SKIPPED[@]}"; do + echo -e " ${YELLOW}⊘${RESET} $s" + done +fi + +if [ ${#FAILURES[@]} -gt 0 ]; then + echo -e "\n${RED}${BOLD}Failed (${#FAILURES[@]}):${RESET}" + for f in "${FAILURES[@]}"; do + echo -e " ${RED}✗${RESET} $f" + done + echo "" + echo -e "${RED}${BOLD}CI would fail. Fix the above issues before creating a PR.${RESET}" + exit 1 +fi + +echo "" +echo -e "${GREEN}${BOLD}All checks passed. Ready to create a PR.${RESET}" diff --git a/scripts/operator-statusline.sh b/scripts/operator-statusline.sh new file mode 100755 index 0000000..caaf65b --- /dev/null +++ b/scripts/operator-statusline.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Operator status line for Claude Code sessions. +# Receives session JSON on stdin; reads OPERATOR_* env vars. +# Outputs two lines: Line 1 = cwd + git + UI link, Line 2 = operator context. + +set -o pipefail + +# ANSI color codes +RESET='\033[0m' +BOLD='\033[1m' +BLUE_BG='\033[44m' +GREEN_BG='\033[42m' +YELLOW_BG='\033[43m' +CYAN_BG='\033[46m' +MAGENTA_BG='\033[45m' +BLACK_FG='\033[30m' +WHITE_FG='\033[97m' + +# Parse stdin JSON (Claude Code pipes session context) +if command -v jq >/dev/null 2>&1; then + INPUT=$(cat) + CWD=$(echo "$INPUT" | jq -r '.cwd // .workspace.current_dir // empty' 2>/dev/null) + MODEL=$(echo "$INPUT" | jq -r '.model.display_name // .model // empty' 2>/dev/null) + CTX_USED=$(echo "$INPUT" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) +else + # Consume stdin even without jq + cat >/dev/null + CWD="" + MODEL="" + CTX_USED="" +fi + +# Fallbacks +CWD="${CWD:-$(pwd)}" + +# Shorten home directory to ~ +home="$HOME" +SHORT_CWD="${CWD/#$home/\~}" + +# Git info (only if cwd is valid) +GIT_BRANCH="" +GIT_DIRTY="" +if [ -d "$CWD" ]; then + GIT_BRANCH=$(git -C "$CWD" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null) + if [ -n "$GIT_BRANCH" ]; then + GIT_STATUS=$(git -C "$CWD" --no-optional-locks status --porcelain 2>/dev/null) + if [ -n "$GIT_STATUS" ]; then + GIT_DIRTY=" ✚" + fi + fi +fi + +# --- Line 1: cwd | git branch | View in UI --- +LINE1="" + +# Directory segment +LINE1="${LINE1}$(printf "${BLUE_BG}${BLACK_FG}${BOLD} %s ${RESET}" "$SHORT_CWD")" + +# Git segment +if [ -n "$GIT_BRANCH" ]; then + if [ -n "$GIT_DIRTY" ]; then + LINE1="${LINE1}$(printf "${YELLOW_BG}${BLACK_FG}${BOLD} ± %s%s ${RESET}" "$GIT_BRANCH" "$GIT_DIRTY")" + else + LINE1="${LINE1}$(printf "${GREEN_BG}${BLACK_FG}${BOLD} ± %s ${RESET}" "$GIT_BRANCH")" + fi +fi + +# View in UI link (OSC 8 hyperlink if OPERATOR_UI_URL is set) +if [ -n "$OPERATOR_UI_URL" ]; then + LINE1="${LINE1}$(printf " \033]8;;%s\033\\${BOLD}View in UI${RESET}\033]8;;\033\\" "$OPERATOR_UI_URL")" +fi + +# --- Line 2: [OPR8R] ticket | project | model | ctx:% --- +LINE2="" + +# Operator badge +LINE2="${LINE2}$(printf "${MAGENTA_BG}${WHITE_FG}${BOLD} OPR8R ${RESET}")" + +# Ticket ID +if [ -n "$OPERATOR_TICKET_ID" ]; then + LINE2="${LINE2}$(printf " %s" "$OPERATOR_TICKET_ID")" +fi + +# Project +if [ -n "$OPERATOR_PROJECT" ]; then + LINE2="${LINE2}$(printf " | %s" "$OPERATOR_PROJECT")" +fi + +# Model +if [ -n "$MODEL" ]; then + LINE2="${LINE2}$(printf " ${CYAN_BG}${BLACK_FG} %s ${RESET}" "$MODEL")" +fi + +# Context usage +if [ -n "$CTX_USED" ]; then + CTX_INT=$(printf "%.0f" "$CTX_USED" 2>/dev/null || echo "$CTX_USED") + LINE2="${LINE2}$(printf " ${WHITE_FG}ctx:%s%%${RESET}" "$CTX_INT")" +fi + +printf "%b\n%b\n" "$LINE1" "$LINE2" diff --git a/shared/types.ts b/shared/types.ts index 7bf36da..3a2924e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -34,7 +34,7 @@ default_branch: string | null, */ ai_context_path: string | null, /** - * Backstage taxonomy kind (tier 1-5) + * Project taxonomy kind (tier 1-5) */ kind: string | null, /** @@ -272,7 +272,7 @@ projects: Array, agents: AgentsConfig, notifications: NotificationsConfi /** * Session wrapper configuration (tmux, vscode, or cmux) */ -sessions: SessionsConfig, llm_tools: LlmToolsConfig, backstage: BackstageConfig, rest_api: RestApiConfig, git: GitConfig, +sessions: SessionsConfig, llm_tools: LlmToolsConfig, rest_api: RestApiConfig, git: GitConfig, /** * Kanban provider configuration for syncing issues from Jira, Linear, etc. */ @@ -289,9 +289,26 @@ delegators: Array, * User-declared model servers (ollama, lmstudio, any OpenAI-compat host). * Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. */ -model_servers: Array, }; +model_servers: Array, +/** + * Relay MCP injection configuration + */ +relay: RelayConfig, +/** + * Model Context Protocol (MCP) server configuration + */ +mcp: McpConfig, +/** + * Agent Client Protocol (ACP) agent configuration + */ +acp: AcpConfig, }; -export type AgentsConfig = { max_parallel: number, cores_reserved: number, health_check_interval: bigint, +export type AgentsConfig = { max_parallel: number, cores_reserved: number, +/** + * Maximum concurrent agents per project/repo (default: 1). + * Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + */ +max_agents_per_repo: number, health_check_interval: bigint, /** * Timeout in seconds for each agent generation (default: 300 = 5 min) */ @@ -383,85 +400,6 @@ export type TmuxConfig = { */ config_generated: boolean, }; -export type BackstageConfig = { -/** - * Whether Backstage integration is enabled - */ -enabled: boolean, -/** - * Whether to show Backstage in the Connections status section - */ -display: boolean, -/** - * Port for the Backstage server - */ -port: number, -/** - * Auto-start Backstage server when TUI launches - */ -auto_start: boolean, -/** - * Subdirectory within `state_path` for Backstage installation - */ -subpath: string, -/** - * Subdirectory within backstage path for branding customization - */ -branding_subpath: string, -/** - * Base URL for downloading backstage-server binary - */ -release_url: string, -/** - * Optional local path to backstage-server binary - * If set, this is used instead of downloading from `release_url` - */ -local_binary_path: string | null, -/** - * Branding and theming configuration - */ -branding: BrandingConfig, }; - -export type BrandingConfig = { -/** - * App title shown in header - */ -app_title: string, -/** - * Organization name - */ -org_name: string, -/** - * Path to logo SVG (relative to branding path) - */ -logo_path: string | null, -/** - * Theme colors (uses Operator defaults if not set) - */ -colors: ThemeColors, }; - -export type ThemeColors = { -/** - * Primary/accent color (default: salmon #cc6c55) - */ -primary: string, -/** - * Secondary color (default: dark teal #114145) - */ -secondary: string, -/** - * Accent/highlight color (default: cream #f4dbb7) - */ -accent: string, -/** - * Warning/error color (default: coral #d46048) - */ -warning: string, -/** - * Muted text color (default: darker salmon #8a4a3a) - */ -muted: string, }; - export type RestApiConfig = { /** * Whether the REST API is enabled @@ -663,7 +601,11 @@ prompt_prefix: string | null, /** * Prompt text to append after the generated step prompt */ -prompt_suffix: string | null, }; +prompt_suffix: string | null, +/** + * Override global relay auto-inject MCP setting per-delegator (None = use global setting) + */ +operator_relay: boolean | null, }; export type CollectionPreset = "simple" | "dev_kanban" | "devops_kanban" | "custom"; @@ -843,6 +785,65 @@ export type HealthResponse = { status: string, version: string, }; export type StatusResponse = { status: string, version: string, issuetype_count: number, collection_count: number, active_collection: string, }; +export type SectionDto = { +/** + * Stable section id (e.g. "config", "connections", "kanban"). + */ +id: string, label: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, description: string, +/** + * Section ids that must be Green before this section is usable. + */ +prerequisites: Array, +/** + * Whether all prerequisites are met. Sections are always returned (the web + * UI styles unmet ones as locked) rather than hidden by progressive disclosure. + */ +met: boolean, children: Array, }; + +export type SectionRowDto = { +/** + * Stable, section-scoped row id. Clients use it as a tree key and to route + * row-specific commands without matching on the (mutable) display label. + * Dynamic rows carry their entity key (issue-type key, project name); + * static rows carry a fixed slug (e.g. "git-token"). + */ +id: string, +/** + * Nesting depth within the section (1 = direct child, 2 = grandchild). + * Lets clients rebuild the tree (e.g. LLM tools → model aliases). + */ +depth: number, label: string, description: string, +/** + * Icon hint (e.g. "check", "warning", "tool", "folder"). + */ +icon: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, }; + +export type WorkflowExportResponse = { +/** + * The ticket the workflow was generated from. + */ +ticket_id: string, +/** + * The issue type key that supplied the step structure. + */ +issuetype_key: string, +/** + * Suggested filename for saving the workflow (`.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source. + */ +contents: string, }; + export type SkillEntry = { /** * Tool this skill belongs to (e.g., "claude", "codex") @@ -973,7 +974,11 @@ prompt_prefix: string | null, /** * Prompt text to append after the generated step prompt */ -prompt_suffix: string | null, }; +prompt_suffix: string | null, +/** + * Override global relay auto-inject MCP setting per-delegator (None = use global setting) + */ +operator_relay: boolean | null, }; export type LlmTask = { /** diff --git a/src/acp/agent.rs b/src/acp/agent.rs new file mode 100644 index 0000000..061ea95 --- /dev/null +++ b/src/acp/agent.rs @@ -0,0 +1,300 @@ +//! Operator's ACP agent over stdio. +//! +//! Wires the [`agent_client_protocol::Agent`] role builder up to a [`Stdio`] +//! transport. Editors that speak ACP launch `operator acp` as a subprocess +//! and exchange line-delimited JSON-RPC with this loop. + +use std::process::Stdio as ProcStdio; +use std::sync::Arc; + +use agent_client_protocol::schema::{ + AgentCapabilities, CancelNotification, ContentBlock, Implementation, InitializeRequest, + InitializeResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SessionId, SessionNotification, StopReason, +}; +use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Stdio}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::oneshot; + +use crate::acp::session::SessionRegistry; +use crate::acp::translator; +use crate::config::{Config, Delegator}; + +/// Build the `InitializeResponse` operator advertises. +/// +/// Echoes the client's protocol version (the ACP convention — the agent +/// accepts the protocol version requested unless it cannot satisfy it), +/// advertises default agent capabilities, and attaches `agentInfo` so +/// editors can identify operator in their UI. +pub fn build_initialize_response(request: &InitializeRequest) -> InitializeResponse { + InitializeResponse::new(request.protocol_version) + .agent_capabilities(AgentCapabilities::default()) + .agent_info(Implementation::new("operator", env!("CARGO_PKG_VERSION")).title("Operator")) +} + +/// Run operator as an ACP agent over stdin/stdout until the client +/// disconnects. +/// +/// Returns the protocol's `Result` so the binary entrypoint can surface +/// transport errors. Logs go to stderr via `tracing`; stdout is reserved +/// for line-delimited JSON-RPC (see `src/logging.rs` — global subscriber +/// writes to stderr). +pub async fn run_stdio(config: Config) -> agent_client_protocol::Result<()> { + let registry = Arc::new(SessionRegistry::new()); + let config = Arc::new(config); + + let new_session_registry = Arc::clone(®istry); + let new_session_config = Arc::clone(&config); + let prompt_registry = Arc::clone(®istry); + let prompt_config = Arc::clone(&config); + let cancel_registry = Arc::clone(®istry); + + Agent + .builder() + .name("operator") + .on_receive_request( + async move |request: InitializeRequest, responder, _connection| { + responder.respond(build_initialize_response(&request)) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: NewSessionRequest, responder, _connection| { + match new_session_registry.create_or_attach(&new_session_config, &request.cwd) { + Ok(session_id) => { + tracing::info!(?session_id, cwd = %request.cwd.display(), "ACP session opened"); + responder.respond(NewSessionResponse::new(session_id)) + } + Err(err) => responder.respond_with_error( + agent_client_protocol::util::internal_error(format!( + "session/new failed: {err}" + )), + ), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: PromptRequest, responder, connection| { + let reg = Arc::clone(&prompt_registry); + let cfg = Arc::clone(&prompt_config); + let cx = connection.clone(); + connection.spawn(async move { + let response = handle_prompt(®, &cfg, request, &cx).await; + match response { + Ok(resp) => responder.respond(resp)?, + Err(message) => responder.respond_with_error( + agent_client_protocol::util::internal_error(message), + )?, + } + Ok(()) + })?; + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |notif: CancelNotification, _connection| { + tracing::info!(session_id = ?notif.session_id, "ACP cancel received"); + if let Some(tx) = cancel_registry.take_cancel_sender(¬if.session_id) { + let _ = tx.send(()); + tracing::info!(session_id = ?notif.session_id, "ACP cancel signal sent to delegator"); + } + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + let method = message.method().to_string(); + message.respond_with_error( + agent_client_protocol::util::internal_error(format!( + "ACP method not implemented: {method}" + )), + cx, + ) + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_to(Stdio::new()) + .await +} + +/// Concatenate the text content blocks of a `PromptRequest`. +/// +/// v1 supports text only; other `ContentBlock` variants (image, audio, +/// resource) are skipped. The result is one newline-joined string suitable +/// for piping to a CLI delegator's prompt file. +fn flatten_prompt(blocks: &[ContentBlock]) -> String { + blocks + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(t.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Pick the delegator to use for an ACP prompt: prefer `[acp].default_delegator` +/// by name, then fall back to `agents::delegator_resolution::resolve_default_delegator`. +fn resolve_acp_delegator(config: &Config) -> Option<&Delegator> { + if let Some(name) = config.acp.default_delegator.as_deref() { + if let Some(d) = config.delegators.iter().find(|d| d.name == name) { + return Some(d); + } + tracing::warn!( + requested = name, + "acp.default_delegator name not found; falling back to default resolver" + ); + } + crate::agents::delegator_resolution::resolve_default_delegator(config) +} + +/// Run the prompt → delegator → stream-back-to-editor pipeline. +/// +/// Returns the prompt response on success, or an `Err(String)` message that +/// the caller will wrap in `internal_error`. +async fn handle_prompt( + registry: &SessionRegistry, + config: &Config, + request: PromptRequest, + connection: &ConnectionTo, +) -> Result { + let session_id = request.session_id.clone(); + let session = registry + .get(&session_id) + .ok_or_else(|| format!("unknown ACP session: {}", session_id.0))?; + + let prompt_text = flatten_prompt(&request.prompt); + let delegator = resolve_acp_delegator(config) + .cloned() + .ok_or_else(|| "no delegator configured for ACP prompts".to_string())?; + + let session_id_str = session_id.0.to_string(); + let prompt_file = + crate::agents::launcher::prompt::write_prompt_file(config, &session_id_str, &prompt_text) + .map_err(|e| format!("write_prompt_file: {e}"))?; + + let mut command_string = + crate::agents::launcher::llm_command::build_llm_command_with_permissions_for_tool( + config, + &delegator.llm_tool, + &delegator.model, + &session_id_str, + &prompt_file, + None, + None, + Some(false), + ) + .map_err(|e| format!("build_llm_command: {e}"))?; + + if delegator.llm_tool == "claude" { + command_string.push_str(" --output-format stream-json"); + } + + tracing::info!( + ?session_id, + delegator = %delegator.name, + cwd = %session.working_directory.display(), + "ACP prompt: spawning delegator" + ); + + let (cancel_tx, cancel_rx) = oneshot::channel(); + registry.register_cancel_sender(&session_id, cancel_tx); + + let stop_reason = stream_delegator( + &command_string, + &session.working_directory, + &session_id, + connection, + cancel_rx, + ) + .await + .map_err(|e| format!("delegator subprocess: {e}"))?; + + registry.take_cancel_sender(&session_id); + + Ok(PromptResponse::new(stop_reason)) +} + +/// Spawn the delegator via `bash -lc ` in `cwd`, stream stdout +/// line-by-line as ACP `AgentMessageChunk` notifications, and return the +/// final `StopReason` based on exit status. If `cancel_rx` fires, the +/// child process is killed and `StopReason::Cancelled` is returned. +async fn stream_delegator( + command_string: &str, + cwd: &std::path::Path, + session_id: &SessionId, + connection: &ConnectionTo, + mut cancel_rx: oneshot::Receiver<()>, +) -> std::io::Result { + let mut child = Command::new("bash") + .arg("-lc") + .arg(command_string) + .current_dir(cwd) + .stdin(ProcStdio::null()) + .stdout(ProcStdio::piped()) + .stderr(ProcStdio::piped()) + .spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| std::io::Error::other("failed to capture delegator stdout"))?; + + let mut lines = BufReader::new(stdout).lines(); + loop { + tokio::select! { + line_result = lines.next_line() => { + match line_result? { + Some(line) => { + if let Some(update) = translator::line_to_update(&line) { + let notif = SessionNotification::new(session_id.clone(), update); + if let Err(e) = connection.send_notification(notif) { + tracing::warn!(error = %e, "ACP send_notification failed"); + break; + } + } + } + None => break, + } + } + _ = &mut cancel_rx => { + tracing::info!(?session_id, "ACP cancel: killing delegator subprocess"); + child.kill().await.ok(); + return Ok(StopReason::Cancelled); + } + } + } + + let status = child.wait().await?; + Ok(if status.success() { + StopReason::EndTurn + } else { + StopReason::Refusal + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::ProtocolVersion; + + #[test] + fn test_initialize_response_echoes_protocol_version() { + let request = InitializeRequest::new(ProtocolVersion::V1); + let response = build_initialize_response(&request); + assert_eq!(response.protocol_version, ProtocolVersion::V1); + } + + #[test] + fn test_initialize_response_advertises_agent_info() { + let request = InitializeRequest::new(ProtocolVersion::V1); + let response = build_initialize_response(&request); + let info = response.agent_info.expect("agent_info must be populated"); + assert_eq!(info.name, "operator"); + assert_eq!(info.version, env!("CARGO_PKG_VERSION")); + } +} diff --git a/src/acp/client_configs.rs b/src/acp/client_configs.rs new file mode 100644 index 0000000..53a8216 --- /dev/null +++ b/src/acp/client_configs.rs @@ -0,0 +1,123 @@ +//! Generates copy-paste ACP agent registrations pointing at this operator +//! binary. +//! +//! Each `*_snippet()` returns either a `serde_json::Value` (Zed, `JetBrains`) +//! or a plain `String` (Emacs elisp, Kiro TOML), shaped the way the target +//! editor expects it. The dashboard writes one of these to +//! `/operator/acp/.{json,el,toml}` and opens it in the +//! user's editor; the user pastes the contents into their actual editor +//! configuration. + +use serde_json::{json, Value}; +use std::path::PathBuf; + +/// Path to the currently-running operator binary. Falls back to bare +/// `"operator"` if `current_exe` is unavailable (e.g. in some test contexts). +pub fn current_exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +fn exe_string() -> String { + current_exe().to_string_lossy().into_owned() +} + +/// Zed `~/.config/zed/settings.json` — `agent_servers` block. +pub fn zed_snippet() -> Value { + json!({ + "agent_servers": { + "operator": { + "command": exe_string(), + "args": ["acp"], + "env": {} + } + } + }) +} + +/// `JetBrains` ACP Agent Registry JSON. Imported via the IDE's ACP plugin. +pub fn jetbrains_snippet() -> Value { + json!({ + "name": "operator", + "displayName": "Operator (Kanban Orchestrator)", + "command": exe_string(), + "args": ["acp"] + }) +} + +/// Emacs `agent-shell` — elisp form to add to your init file. +pub fn emacs_snippet() -> String { + format!( + "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))", + exe_string() + ) +} + +/// Kiro `~/.kiro/agents.toml` entry. +pub fn kiro_snippet() -> String { + format!( + "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n", + exe_string() + ) +} + +/// Dispatch by editor name. Returns `None` for unknown editors. JSON-shaped +/// editors (Zed, `JetBrains`) return their snippet directly; text-format +/// editors (Emacs, Kiro) are wrapped as `Value::String` so callers can treat +/// the result uniformly. +pub fn snippet_for(editor: &str) -> Option { + match editor { + "zed" => Some(zed_snippet()), + "jetbrains" => Some(jetbrains_snippet()), + "emacs" => Some(Value::String(emacs_snippet())), + "kiro" => Some(Value::String(kiro_snippet())), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zed_snippet_shape() { + let cfg = zed_snippet(); + assert_eq!(cfg["agent_servers"]["operator"]["args"][0], "acp"); + assert!(cfg["agent_servers"]["operator"]["command"].is_string()); + } + + #[test] + fn test_jetbrains_snippet_has_name_and_command() { + let cfg = jetbrains_snippet(); + assert_eq!(cfg["name"], "operator"); + assert_eq!(cfg["args"][0], "acp"); + } + + #[test] + fn test_emacs_snippet_is_valid_elisp_form() { + let snippet = emacs_snippet(); + assert!(snippet.starts_with("(add-to-list 'agent-shell-acp-agents")); + assert!(snippet.contains("operator")); + assert!(snippet.contains(":args (\"acp\")")); + } + + #[test] + fn test_kiro_snippet_is_toml_array_entry() { + let snippet = kiro_snippet(); + assert!(snippet.starts_with("[[agents]]")); + assert!(snippet.contains("name = \"operator\"")); + assert!(snippet.contains("args = [\"acp\"]")); + } + + #[test] + fn test_snippet_for_unknown_editor_is_none() { + assert!(snippet_for("notepad++").is_none()); + } + + #[test] + fn test_snippet_for_dispatches_all_editors() { + assert!(snippet_for("zed").is_some()); + assert!(snippet_for("jetbrains").is_some()); + assert!(snippet_for("emacs").is_some()); + assert!(snippet_for("kiro").is_some()); + } +} diff --git a/src/acp/mod.rs b/src/acp/mod.rs new file mode 100644 index 0000000..cfffdc9 --- /dev/null +++ b/src/acp/mod.rs @@ -0,0 +1,23 @@ +//! Agent Client Protocol (ACP) integration for Operator. +//! +//! Operator runs as an ACP agent that editors (Zed, `JetBrains`, Emacs, +//! Kiro, `OpenCode`, marimo, Eclipse) launch as a stdio subprocess. Each +//! ACP session maps to one operator ticket (created or attached when +//! the editor's cwd matches an in-progress ticket). +//! +//! Phase A (this file's current scope): only `initialize` is handled. +//! Sessions, prompts, and editor config snippets land in Phase B. +//! +//! See: + +pub mod agent; +pub mod client_configs; +pub mod server; +pub mod session; +pub mod translator; + +pub use agent::run_stdio; +pub use server::{AcpAgentServer, AcpAgentStatus}; +// SessionRegistry and AcpSession are intentionally not re-exported at the +// `acp::*` root: they're internal to the agent runtime. Callers that need +// them can use `acp::session::*`. diff --git a/src/acp/server.rs b/src/acp/server.rs new file mode 100644 index 0000000..f5f991f --- /dev/null +++ b/src/acp/server.rs @@ -0,0 +1,93 @@ +//! ACP agent status/count handle for the dashboard. +//! +//! Unlike [`crate::rest::server::RestApiServer`], this is **not** a listener +//! lifecycle — editor-spawned `operator acp` runs in a separate stdio +//! subprocess that the TUI never hosts. [`AcpAgentServer`] just records +//! whether ACP is advertised in the dashboard and how many sessions are +//! currently active (always `0` in v1, since out-of-process ACP runs don't +//! report back to the TUI). +//! +//! When a shared file/socket bridge is added later, `active_sessions` can be +//! populated from there without changing this handle's shape. + +use std::sync::{Arc, Mutex}; + +use crate::config::Config; + +/// Coarse status reported to the dashboard. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AcpAgentStatus { + /// `[acp].stdio_advertised = false` — operator is intentionally not + /// advertising itself as an ACP agent. + Disabled, + /// Advertised. No active sessions are visible to the TUI (the editor + /// runs `operator acp` out-of-process in v1, so the count is always 0 + /// here). The dashboard can still surface "ready" and offer config + /// snippets. + Advertised { active_sessions: usize }, +} + +impl AcpAgentStatus { + pub fn is_advertised(&self) -> bool { + matches!(self, AcpAgentStatus::Advertised { .. }) + } + + pub fn active_sessions(&self) -> usize { + match self { + AcpAgentStatus::Advertised { active_sessions } => *active_sessions, + AcpAgentStatus::Disabled => 0, + } + } +} + +use serde::{Deserialize, Serialize}; + +/// Status handle wired into `App` and read by the dashboard. Shape mirrors +/// the lock-protected pattern of [`crate::rest::server::RestApiServer`] so +/// future TUI-launched listeners can slot in without changing call sites. +#[derive(Debug, Clone)] +pub struct AcpAgentServer { + status: Arc>, +} + +impl AcpAgentServer { + /// Construct from a config snapshot. Honors `config.acp.stdio_advertised`. + pub fn from_config(config: &Config) -> Self { + let status = if config.acp.stdio_advertised { + AcpAgentStatus::Advertised { active_sessions: 0 } + } else { + AcpAgentStatus::Disabled + }; + Self { + status: Arc::new(Mutex::new(status)), + } + } + + /// Current status (cloned out of the mutex). + pub fn status(&self) -> AcpAgentStatus { + self.status.lock().unwrap().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_config_advertised_default() { + let config = Config::default(); + let server = AcpAgentServer::from_config(&config); + assert!(server.status().is_advertised()); + assert_eq!(server.status().active_sessions(), 0); + } + + #[test] + fn test_from_config_disabled_when_flag_off() { + let mut config = Config::default(); + config.acp.stdio_advertised = false; + let server = AcpAgentServer::from_config(&config); + assert!(!server.status().is_advertised()); + assert_eq!(server.status(), AcpAgentStatus::Disabled); + } +} diff --git a/src/acp/session.rs b/src/acp/session.rs new file mode 100644 index 0000000..a567871 --- /dev/null +++ b/src/acp/session.rs @@ -0,0 +1,347 @@ +//! ACP session registry — maps `SessionId` to operator tickets. +//! +//! When an editor calls `session/new`, [`SessionRegistry::create_or_attach`] +//! either attaches to an existing in-progress ACP ticket (if exactly one +//! matches the editor's cwd) or writes a fresh `ACP-{short}.md` into +//! `.tickets/in-progress/` and registers the session against it. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use agent_client_protocol::schema::SessionId; +use anyhow::{anyhow, Context, Result}; +use tokio::sync::oneshot; + +use crate::config::Config; + +/// One live ACP session: an editor-spawned conversation backed by an +/// `ACP-*.md` ticket in the in-progress directory. +#[derive(Debug, Clone)] +pub struct AcpSession { + /// Reserved for future ticket-completion bookkeeping (mark the ACP + /// ticket done when the editor disconnects). + #[allow(dead_code)] + pub session_id: SessionId, + /// Reserved for future ticket-update logic. + #[allow(dead_code)] + pub ticket_path: PathBuf, + pub working_directory: PathBuf, +} + +/// Thread-safe registry of live ACP sessions. Handler closures share this +/// across `Agent.builder()` registrations via `Arc`. +#[derive(Debug, Default, Clone)] +pub struct SessionRegistry { + sessions: Arc>>, + cancel_senders: Arc>>>, +} + +impl SessionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Create or attach an ACP session for `cwd`. + /// + /// Behavior: + /// 1. Reject if the registry already holds `config.acp.max_concurrent_sessions`. + /// 2. Canonicalize `cwd` (falling back to the literal path on error). + /// 3. If exactly one `ACP-*.md` ticket in `in-progress/` has matching + /// frontmatter `cwd`, attach to it (do not write a new ticket). + /// 4. Otherwise write a fresh `ACP-{session-short}.md` to `in-progress/`. + pub fn create_or_attach(&self, config: &Config, cwd: &Path) -> Result { + { + let active = self.sessions.lock().unwrap().len(); + if active >= config.acp.max_concurrent_sessions { + return Err(anyhow!( + "ACP session limit reached: {active}/{}", + config.acp.max_concurrent_sessions + )); + } + } + + let canonical_cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let in_progress = config.tickets_path().join("in-progress"); + let session_id = SessionId::from(uuid::Uuid::new_v4().to_string()); + + let ticket_path = match find_matching_acp_ticket(&in_progress, &canonical_cwd) { + Some(path) => path, + None => write_new_acp_ticket(&in_progress, &session_id, &canonical_cwd)?, + }; + + let session = AcpSession { + session_id: session_id.clone(), + ticket_path, + working_directory: canonical_cwd, + }; + self.sessions + .lock() + .unwrap() + .insert(session_id.clone(), session); + Ok(session_id) + } + + /// Number of live ACP sessions. Used by `AcpAgentServer::active_sessions` + /// once the registry is shared with the dashboard. + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.sessions.lock().unwrap().len() + } + + /// Reserved for the same future as `len`. + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.sessions.lock().unwrap().is_empty() + } + + /// Return a clone of the session matching `id`, if any. + pub fn get(&self, id: &SessionId) -> Option { + self.sessions.lock().unwrap().get(id).cloned() + } + + /// Store a cancel sender for an in-flight prompt. The cancel notification + /// handler calls [`take_cancel_sender`] to fire it. + pub fn register_cancel_sender(&self, id: &SessionId, tx: oneshot::Sender<()>) { + self.cancel_senders.lock().unwrap().insert(id.clone(), tx); + } + + /// Remove and return the cancel sender for `id`, if one is registered. + /// Returns `None` if the prompt already completed (sender was cleaned up) + /// or if no prompt is in flight for this session. + pub fn take_cancel_sender(&self, id: &SessionId) -> Option> { + self.cancel_senders.lock().unwrap().remove(id) + } +} + +/// Scan `in_progress` for `ACP-*.md` files whose frontmatter `cwd` matches +/// `target`. Returns `Some(path)` iff exactly one matches; otherwise `None`. +fn find_matching_acp_ticket(in_progress: &Path, target: &Path) -> Option { + let entries = std::fs::read_dir(in_progress).ok()?; + let matches: Vec = entries + .filter_map(std::result::Result::ok) + .map(|e| e.path()) + .filter(|p| { + p.extension().and_then(|e| e.to_str()) == Some("md") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("ACP-")) + }) + .filter(|p| ticket_cwd_matches(p, target)) + .collect(); + if matches.len() == 1 { + Some(matches.into_iter().next().unwrap()) + } else { + None + } +} + +/// True iff the file at `path` has YAML frontmatter with a `cwd` field that +/// equals `target` after path comparison. +fn ticket_cwd_matches(path: &Path, target: &Path) -> bool { + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return false; + } + let after_open = &trimmed[3..]; + let Some(end_idx) = after_open.find("\n---") else { + return false; + }; + let yaml_str = after_open[..end_idx].trim(); + let Ok(fm) = serde_yaml::from_str::>(yaml_str) else { + return false; + }; + fm.get("cwd") + .and_then(serde_yaml::Value::as_str) + .is_some_and(|s| Path::new(s) == target) +} + +fn write_new_acp_ticket(in_progress: &Path, session_id: &SessionId, cwd: &Path) -> Result { + std::fs::create_dir_all(in_progress) + .with_context(|| format!("create in-progress dir {}", in_progress.display()))?; + let short = session_short(session_id); + let filename = format!("ACP-{short}.md"); + let path = in_progress.join(filename); + let now = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let cwd_str = cwd.display().to_string(); + let project = cwd.file_name().and_then(|n| n.to_str()).unwrap_or("global"); + let body = format!( + "---\nid: ACP-{short}\nstatus: in-progress\nkind: acp\ncreated: {now}\nproject: {project}\ncwd: {cwd_str}\n---\n\n# ACP session from {cwd_str}\n" + ); + std::fs::write(&path, body).with_context(|| format!("write ACP ticket {}", path.display()))?; + Ok(path) +} + +/// First 8 hex chars of the session UUID — short enough for a filename, long +/// enough that random collisions inside one in-progress dir are negligible. +fn session_short(session_id: &SessionId) -> String { + session_id + .0 + .chars() + .filter(char::is_ascii_hexdigit) + .take(8) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Once; + + static INIT: Once = Once::new(); + + fn test_config(tickets_dir: &Path, max_sessions: usize) -> Config { + INIT.call_once(|| {}); + let mut config = Config::default(); + config.paths.tickets = tickets_dir.to_string_lossy().into_owned(); + config.acp.max_concurrent_sessions = max_sessions; + config + } + + fn write_acp_ticket(in_progress: &Path, name: &str, cwd: &Path) { + std::fs::create_dir_all(in_progress).unwrap(); + let body = format!( + "---\nid: ACP-{name}\nstatus: in-progress\nkind: acp\ncreated: 2026-05-17\nproject: test\ncwd: {}\n---\n\n# pre-existing\n", + cwd.display() + ); + std::fs::write(in_progress.join(format!("ACP-{name}.md")), body).unwrap(); + } + + #[test] + fn test_attaches_when_one_acp_ticket_matches_cwd() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canon_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let in_progress = tickets.path().join("in-progress"); + write_acp_ticket(&in_progress, "abcd1234", &canon_cwd); + + let config = test_config(tickets.path(), 4); + let registry = SessionRegistry::new(); + let session_id = registry.create_or_attach(&config, cwd.path()).unwrap(); + + let session = registry + .sessions + .lock() + .unwrap() + .get(&session_id) + .cloned() + .expect("session must be registered"); + assert_eq!(session.ticket_path, in_progress.join("ACP-abcd1234.md")); + assert_eq!(session.working_directory, canon_cwd); + // No new file written: only the pre-seeded one exists + let count = std::fs::read_dir(&in_progress).unwrap().count(); + assert_eq!(count, 1); + } + + #[test] + fn test_creates_new_ticket_when_no_match() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let in_progress = tickets.path().join("in-progress"); + + let config = test_config(tickets.path(), 4); + let registry = SessionRegistry::new(); + let session_id = registry.create_or_attach(&config, cwd.path()).unwrap(); + + let path = registry + .sessions + .lock() + .unwrap() + .get(&session_id) + .unwrap() + .ticket_path + .clone(); + assert!(path.exists()); + assert!(path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("ACP-")); + assert_eq!(std::fs::read_dir(&in_progress).unwrap().count(), 1); + } + + #[test] + fn test_creates_new_when_multiple_acp_tickets_match() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canon_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let in_progress = tickets.path().join("in-progress"); + write_acp_ticket(&in_progress, "aaaaaaaa", &canon_cwd); + write_acp_ticket(&in_progress, "bbbbbbbb", &canon_cwd); + + let config = test_config(tickets.path(), 4); + let registry = SessionRegistry::new(); + let session_id = registry.create_or_attach(&config, cwd.path()).unwrap(); + + let path = registry + .sessions + .lock() + .unwrap() + .get(&session_id) + .unwrap() + .ticket_path + .clone(); + // Should be a brand-new file, not one of the two pre-seeded ones + assert!(path.exists()); + assert_ne!(path, in_progress.join("ACP-aaaaaaaa.md")); + assert_ne!(path, in_progress.join("ACP-bbbbbbbb.md")); + assert_eq!(std::fs::read_dir(&in_progress).unwrap().count(), 3); + } + + #[test] + fn test_rejects_when_max_concurrent_sessions_reached() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let config = test_config(tickets.path(), 1); + let registry = SessionRegistry::new(); + registry.create_or_attach(&config, cwd.path()).unwrap(); + + let err = registry + .create_or_attach(&config, cwd.path()) + .expect_err("second session must be rejected"); + assert!(err.to_string().contains("session limit")); + } + + #[test] + fn test_session_short_is_8_hex_chars() { + let id = SessionId::from("deadbeef-cafe-1234-5678-90abcdef0000".to_string()); + let short = session_short(&id); + assert_eq!(short.len(), 8); + assert!(short.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_register_and_take_cancel_sender() { + let registry = SessionRegistry::new(); + let id = SessionId::from("test-session-1".to_string()); + let (tx, _rx) = oneshot::channel(); + registry.register_cancel_sender(&id, tx); + assert!( + registry.take_cancel_sender(&id).is_some(), + "first take should return the sender" + ); + } + + #[test] + fn test_double_take_cancel_sender_returns_none() { + let registry = SessionRegistry::new(); + let id = SessionId::from("test-session-2".to_string()); + let (tx, _rx) = oneshot::channel(); + registry.register_cancel_sender(&id, tx); + registry.take_cancel_sender(&id); + assert!( + registry.take_cancel_sender(&id).is_none(), + "second take should return None" + ); + } + + #[test] + fn test_take_cancel_sender_unknown_session_returns_none() { + let registry = SessionRegistry::new(); + let id = SessionId::from("nonexistent".to_string()); + assert!(registry.take_cancel_sender(&id).is_none()); + } +} diff --git a/src/acp/translator.rs b/src/acp/translator.rs new file mode 100644 index 0000000..928f7c8 --- /dev/null +++ b/src/acp/translator.rs @@ -0,0 +1,183 @@ +//! Translate delegator subprocess output into ACP `SessionUpdate` +//! notifications. +//! +//! Two parsing modes: structured JSON (Claude Code's `--output-format +//! stream-json`) and plain text (fallback). `line_to_update` tries JSON +//! first, falls back to plain text on parse failure. + +use agent_client_protocol::schema::{ContentBlock, ContentChunk, SessionUpdate, TextContent}; + +/// Map a single line of delegator stdout to an optional ACP `SessionUpdate`. +/// +/// Tries structured JSON parse first (for delegators like Claude Code with +/// `--output-format stream-json`). Falls back to plain text wrapping. +/// Empty / whitespace-only lines return `None`. +pub fn line_to_update(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.starts_with('{') { + match try_stream_json(trimmed) { + StreamJsonResult::Update(update) => return Some(*update), + StreamJsonResult::Skip => return None, + StreamJsonResult::NotStreamJson => {} + } + } + plain_text_update(trimmed) +} + +enum StreamJsonResult { + Update(Box), + Skip, + NotStreamJson, +} + +/// Wrap a non-empty text line as an `AgentMessageChunk`. +fn plain_text_update(trimmed: &str) -> Option { + let text = TextContent::new(format!("{trimmed}\n")); + let chunk = ContentChunk::new(ContentBlock::Text(text)); + Some(SessionUpdate::AgentMessageChunk(chunk)) +} + +/// Try to parse a line as Claude Code `--output-format stream-json`. +/// +/// Returns `Update` for displayable events, `Skip` for internal events +/// (`tool_use`, `system`), and `NotStreamJson` if it doesn't look like +/// stream-json (so the caller can fall back to plain text). +fn try_stream_json(line: &str) -> StreamJsonResult { + let Ok(obj) = serde_json::from_str::(line) else { + return StreamJsonResult::NotStreamJson; + }; + let Some(event_type) = obj.get("type").and_then(|v| v.as_str()) else { + return StreamJsonResult::NotStreamJson; + }; + + match event_type { + "assistant" => { + let Some(content) = obj + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return StreamJsonResult::Skip; + }; + let mut texts = Vec::new(); + for block in content { + if block.get("type").and_then(|v| v.as_str()) == Some("text") { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + texts.push(t.to_string()); + } + } + } + if texts.is_empty() { + return StreamJsonResult::Skip; + } + let joined = texts.join("\n"); + let text = TextContent::new(format!("{joined}\n")); + let chunk = ContentChunk::new(ContentBlock::Text(text)); + StreamJsonResult::Update(Box::new(SessionUpdate::AgentMessageChunk(chunk))) + } + "result" => { + if let Some(result_text) = obj.get("result").and_then(|v| v.as_str()) { + if !result_text.is_empty() { + let text = TextContent::new(format!("{result_text}\n")); + let chunk = ContentChunk::new(ContentBlock::Text(text)); + return StreamJsonResult::Update(Box::new(SessionUpdate::AgentMessageChunk( + chunk, + ))); + } + } + StreamJsonResult::Skip + } + "tool_use" | "tool_result" | "system" => StreamJsonResult::Skip, + _ => StreamJsonResult::NotStreamJson, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn extract_text(update: SessionUpdate) -> String { + let SessionUpdate::AgentMessageChunk(chunk) = update else { + panic!("expected AgentMessageChunk variant"); + }; + let ContentBlock::Text(text) = chunk.content else { + panic!("expected Text content block"); + }; + text.text + } + + #[test] + fn test_empty_line_yields_none() { + assert!(line_to_update("").is_none()); + assert!(line_to_update(" ").is_none()); + assert!(line_to_update("\t \n").is_none()); + } + + #[test] + fn test_text_line_yields_agent_message_chunk() { + let text = extract_text(line_to_update("hello world").unwrap()); + assert_eq!(text, "hello world\n"); + } + + #[test] + fn test_leading_and_trailing_whitespace_trimmed() { + let text = extract_text(line_to_update(" foo ").unwrap()); + assert_eq!(text, "foo\n"); + } + + #[test] + fn test_stream_json_assistant_text() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello from Claude"}]}}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, "Hello from Claude\n"); + } + + #[test] + fn test_stream_json_assistant_multiple_text_blocks() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, "First\nSecond\n"); + } + + #[test] + fn test_stream_json_result_with_text() { + let line = r#"{"type":"result","result":"Task complete","cost_usd":0.05}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, "Task complete\n"); + } + + #[test] + fn test_stream_json_result_empty_yields_none() { + let line = r#"{"type":"result","result":"","cost_usd":0.01}"#; + assert!(line_to_update(line).is_none()); + } + + #[test] + fn test_stream_json_tool_use_skipped() { + let line = r#"{"type":"tool_use","name":"Read","input":{"path":"/tmp/foo"}}"#; + assert!(line_to_update(line).is_none()); + } + + #[test] + fn test_stream_json_system_event_skipped() { + let line = r#"{"type":"system","message":"thinking..."}"#; + assert!(line_to_update(line).is_none()); + } + + #[test] + fn test_malformed_json_falls_back_to_plain_text() { + let line = r#"{"broken json"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, r#"{"broken json"#.to_string() + "\n"); + } + + #[test] + fn test_json_without_type_field_falls_back_to_plain_text() { + let line = r#"{"key":"value"}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, r#"{"key":"value"}"#.to_string() + "\n"); + } +} diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 190c7d5..9041541 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -86,7 +86,7 @@ pub(crate) fn apply_delegator_launch_config( /// 1. Single configured delegator -> use it /// 2. Delegator matching the user's preferred LLM tool -> use it /// 3. None -> caller falls back to first detected tool + first model alias -fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { +pub(crate) fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { match config.delegators.len() { 0 => None, 1 => Some(&config.delegators[0]), diff --git a/src/agents/generator.rs b/src/agents/generator.rs index 909d9d7..f199618 100644 --- a/src/agents/generator.rs +++ b/src/agents/generator.rs @@ -3,8 +3,8 @@ //! Agent and project ticket creators for operator-managed projects //! //! Creates TASK tickets for generating Claude Code agent files in a project's -//! `.claude/agents/` directory, and ASSESS tickets for Backstage catalog -//! assessment. These tickets can then be launched via the normal operator workflow. +//! `.claude/agents/` directory, and ASSESS tickets for project analysis. +//! These tickets can then be launched via the normal operator workflow. use anyhow::{Context, Result}; use chrono::Local; @@ -158,7 +158,7 @@ pub struct AssessTicketResult { pub project: String, } -/// Creates ASSESS tickets for Backstage catalog assessment +/// Creates ASSESS tickets for project assessment pub struct AssessTicketCreator; impl AssessTicketCreator { @@ -182,9 +182,13 @@ impl AssessTicketCreator { // Filename: YYYYMMDD-HHMM-ASSESS-project.md let filename = format!("{timestamp}-ASSESS-{project_name}.md"); - // Check if catalog-info.yaml already exists - let catalog_exists = project_path.join("catalog-info.yaml").exists(); - let action = if catalog_exists { "Update" } else { "Generate" }; + // Check if project has been previously assessed + let previously_assessed = project_path.join("catalog-info.yaml").exists(); + let action = if previously_assessed { + "Reassess" + } else { + "Assess" + }; // Build ticket content using the ASSESS template format let content = format!( @@ -196,7 +200,7 @@ status: queued created: {datetime} --- -# Assessment: {action} catalog-info.yaml for {project_name} +# Assessment: {action} {project_name} ## Project {project_name} diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index 5da2545..bbc6dd1 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -20,7 +20,7 @@ use super::llm_command::{ use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, - write_prompt_file, + write_prompt_file, OperatorEnvVars, }; use super::SESSION_PREFIX; @@ -78,6 +78,7 @@ pub fn launch_in_cmux_with_options( project_path: &str, initial_prompt: &str, options: &LaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check cmux is available and we're inside cmux cmux.check_available() @@ -164,11 +165,17 @@ pub fn launch_in_cmux_with_options( } if options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?; } // Write the command to a shell script file - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { @@ -217,6 +224,7 @@ pub fn launch_in_cmux_with_relaunch_options( project_path: &str, initial_prompt: &str, options: &RelaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check cmux is available and we're inside cmux cmux.check_available() @@ -320,11 +328,17 @@ pub fn launch_in_cmux_with_relaunch_options( } if options.launch_options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?; } // Write and send command - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + )?; if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 45a0eef..961e302 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -3,6 +3,9 @@ use std::fs; use std::path::PathBuf; +/// Embedded operator status line script, deployed per-session for Claude Code. +const STATUSLINE_SCRIPT: &str = include_str!("../../../scripts/operator-statusline.sh"); + /// TEMPORARILY DISABLED: JSON schema support causes command line length issues. /// Even when writing schemas to files (rather than inline), the --json-schema flag /// with large schema file paths can exceed OS command line limits. @@ -91,6 +94,7 @@ pub fn build_docker_command( config: &Config, inner_cmd: &str, project_path: &str, + operator_env: Option<&std::collections::HashMap>, ) -> Result { let docker_config = &config.launch.docker; @@ -123,6 +127,14 @@ pub fn build_docker_command( docker_args.push(arg.clone()); } + // Add operator environment variables (if provided) + if let Some(env) = operator_env { + for (key, value) in env { + docker_args.push("-e".to_string()); + docker_args.push(format!("{key}={value}")); + } + } + // Add the image docker_args.push(docker_config.image.clone()); @@ -219,6 +231,12 @@ fn generate_config_flags( cli_flags.push("--add-dir".to_string()); cli_flags.push(project_path.to_string()); + // Inject operator status line settings + if let Some(settings_path) = statusline_settings_flag(&session_dir) { + cli_flags.push("--settings".to_string()); + cli_flags.push(settings_path); + } + // Inject relay MCP server based on effective relay setting let hub_available = std::env::var("RELAY_HUB_SOCKET").is_ok(); if resolve_relay_injection(operator_relay, hub_available, config.relay.auto_inject_mcp) { @@ -228,6 +246,27 @@ fn generate_config_flags( } } + // Inject external MCP servers (skip reserved names and duplicates) + let mut seen_names = std::collections::HashSet::new(); + for server in &config.mcp.external_servers { + if !server.enabled { + continue; + } + if server.name == "relay" { + tracing::warn!("external MCP server name \"relay\" is reserved; skipping"); + continue; + } + if !seen_names.insert(&server.name) { + tracing::warn!(name = %server.name, "duplicate external MCP server name; skipping"); + continue; + } + if let Some(config_path) = external_mcp_config_flag(&session_dir, server, project_path) + { + cli_flags.push("--mcp-config".to_string()); + cli_flags.push(config_path); + } + } + // Add JSON schema flag for structured output (when enabled) // Write schema to a file to avoid shell escaping issues with inline JSON // Inline jsonSchema takes precedence over jsonSchemaFile @@ -279,6 +318,139 @@ fn generate_config_flags( } } +/// Write a per-session MCP server config JSON and return the file path. +/// +/// Produces `{ "mcpServers": { "": } }` at +/// `/-mcp.json`. +fn write_mcp_server_config( + session_dir: &std::path::Path, + server_name: &str, + entry: serde_json::Value, +) -> Option { + let filename = format!("{server_name}-mcp.json"); + let config_path = session_dir.join(&filename); + let config = serde_json::json!({ "mcpServers": { (server_name): entry } }); + let content = serde_json::to_string_pretty(&config).ok()?; + fs::write(&config_path, content).ok()?; + Some(config_path.display().to_string()) +} + +/// Expand `${VAR}` patterns using the process environment. +/// Unknown variables expand to the empty string. Single-pass scan so +/// self-referencing values (e.g. `VAR=${VAR}`) don't cause infinite loops. +fn expand_env_vars(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.char_indices().peekable(); + while let Some((i, ch)) = chars.next() { + if ch == '$' { + if let Some(&(_, '{')) = chars.peek() { + chars.next(); // consume '{' + if let Some(close) = input[i + 2..].find('}') { + let var_name = &input[i + 2..i + 2 + close]; + let value = std::env::var(var_name).unwrap_or_default(); + result.push_str(&value); + // skip chars until after the closing '}' + let end_pos = i + 2 + close; + while let Some(&(j, _)) = chars.peek() { + if j > end_pos { + break; + } + chars.next(); + } + continue; + } + result.push('$'); + result.push('{'); + continue; + } + } + result.push(ch); + } + result +} + +/// Resolve an external MCP server config and write it to the session dir. +/// Returns `None` if the server cannot be resolved (missing sidecar, empty command). +fn external_mcp_config_flag( + session_dir: &std::path::Path, + server: &crate::config::ExternalMcpServer, + project_path: &str, +) -> Option { + if let Some(ref discover_path) = server.discover_from { + let resolved = if discover_path.starts_with('/') { + PathBuf::from(discover_path) + } else { + PathBuf::from(project_path).join(discover_path) + }; + if resolved.exists() { + let contents = fs::read_to_string(&resolved).ok()?; + let sidecar: serde_json::Value = serde_json::from_str(&contents).ok()?; + if let Some(mcp_server) = sidecar.get("mcpServer") { + return write_mcp_server_config(session_dir, &server.name, mcp_server.clone()); + } + } + if server.command.is_empty() { + return None; + } + } + + let command = expand_env_vars(&server.command); + if command.is_empty() { + return None; + } + + let args: Vec = server.args.iter().map(|a| expand_env_vars(a)).collect(); + let env: std::collections::HashMap = server + .env + .iter() + .map(|(k, v)| (k.clone(), expand_env_vars(v))) + .collect(); + + let mut entry = serde_json::json!({ + "command": command, + "type": "stdio", + }); + if !args.is_empty() { + entry["args"] = serde_json::json!(args); + } + if !env.is_empty() { + entry["env"] = serde_json::json!(env); + } + + write_mcp_server_config(session_dir, &server.name, entry) +} + +/// Ensure the operator status line script is deployed in the session directory. +/// Returns the absolute path to the script, or `None` on failure. +fn ensure_statusline_script(session_dir: &std::path::Path) -> Option { + let script_path = session_dir.join("operator-statusline.sh"); + if !script_path.exists() { + fs::write(&script_path, STATUSLINE_SCRIPT).ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)); + } + } + Some(script_path) +} + +/// Write a per-session `operator-settings.json` that configures Claude Code's +/// status line to use the operator script, and return the file path. +fn statusline_settings_flag(session_dir: &std::path::Path) -> Option { + let script_path = ensure_statusline_script(session_dir)?; + let settings = serde_json::json!({ + "statusLine": { + "type": "command", + "command": script_path.display().to_string() + } + }); + let settings_file = session_dir.join("operator-settings.json"); + let content = serde_json::to_string_pretty(&settings).ok()?; + fs::write(&settings_file, &content).ok()?; + Some(settings_file.display().to_string()) +} + /// Write a per-session relay MCP config and return the path for `--mcp-config`. /// Returns `None` if the relay command cannot be located. fn relay_mcp_config_flag(session_dir: &std::path::Path) -> Option { @@ -291,16 +463,12 @@ fn relay_mcp_config_flag_with_command( binary: PathBuf, args: Vec, ) -> Option { - let config_path = session_dir.join("relay-mcp.json"); let relay_entry = if args.is_empty() { serde_json::json!({ "command": binary.display().to_string(), "type": "stdio" }) } else { serde_json::json!({ "command": binary.display().to_string(), "args": args, "type": "stdio" }) }; - let config = serde_json::json!({ "mcpServers": { "relay": relay_entry } }); - let content = serde_json::to_string_pretty(&config).ok()?; - fs::write(&config_path, content).ok()?; - Some(config_path.display().to_string()) + write_mcp_server_config(session_dir, "relay", relay_entry) } /// Locate the relay command, returning `(binary_path, extra_args)`. @@ -499,7 +667,8 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude --model sonnet", "/home/user/project"); + let result = + build_docker_command(&config, "claude --model sonnet", "/home/user/project", None); assert!(result.is_ok()); let cmd = result.unwrap(); @@ -512,7 +681,7 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude", "/home/user/project"); + let result = build_docker_command(&config, "claude", "/home/user/project", None); let cmd = result.unwrap(); assert!( @@ -527,7 +696,7 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude", "/home/user/project"); + let result = build_docker_command(&config, "claude", "/home/user/project", None); let cmd = result.unwrap(); assert!( @@ -544,7 +713,7 @@ mod tests { config.launch.docker.env_vars = vec!["ANTHROPIC_API_KEY".to_string(), "HOME=/root".to_string()]; - let result = build_docker_command(&config, "claude", "/project"); + let result = build_docker_command(&config, "claude", "/project", None); let cmd = result.unwrap(); assert!( @@ -565,7 +734,7 @@ mod tests { config.launch.docker.extra_args = vec!["--network=host".to_string(), "--privileged".to_string()]; - let result = build_docker_command(&config, "claude", "/project"); + let result = build_docker_command(&config, "claude", "/project", None); let cmd = result.unwrap(); assert!(cmd.contains("--network=host"), "Should include extra arg 1"); @@ -576,7 +745,7 @@ mod tests { fn test_build_docker_command_no_image_errors() { let config = Config::default(); // image is empty by default - let result = build_docker_command(&config, "claude", "/project"); + let result = build_docker_command(&config, "claude", "/project", None); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -592,7 +761,7 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude --model sonnet", "/project"); + let result = build_docker_command(&config, "claude --model sonnet", "/project", None); let cmd = result.unwrap(); assert!( @@ -1262,4 +1431,322 @@ mod tests { let result = resolve_relay_injection(None, true, true); assert!(result); } + + // ======================================== + // expand_env_vars() tests + // ======================================== + + #[test] + fn test_expand_env_vars_known_var() { + std::env::set_var("_TEST_EXPAND_VAR", "hello"); + let result = expand_env_vars("prefix-${_TEST_EXPAND_VAR}-suffix"); + assert_eq!(result, "prefix-hello-suffix"); + std::env::remove_var("_TEST_EXPAND_VAR"); + } + + #[test] + fn test_expand_env_vars_unknown_var_expands_to_empty() { + std::env::remove_var("_TEST_NONEXISTENT_VAR"); + let result = expand_env_vars("before-${_TEST_NONEXISTENT_VAR}-after"); + assert_eq!(result, "before--after"); + } + + #[test] + fn test_expand_env_vars_no_vars_unchanged() { + let result = expand_env_vars("plain string without vars"); + assert_eq!(result, "plain string without vars"); + } + + #[test] + fn test_expand_env_vars_multiple_vars() { + std::env::set_var("_TEST_A", "alpha"); + std::env::set_var("_TEST_B", "beta"); + let result = expand_env_vars("${_TEST_A}/${_TEST_B}"); + assert_eq!(result, "alpha/beta"); + std::env::remove_var("_TEST_A"); + std::env::remove_var("_TEST_B"); + } + + #[test] + fn test_expand_env_vars_empty_string() { + let result = expand_env_vars(""); + assert_eq!(result, ""); + } + + #[test] + fn test_expand_env_vars_self_referencing_does_not_loop() { + std::env::set_var("_TEST_SELF_REF", "${_TEST_SELF_REF}"); + let result = expand_env_vars("${_TEST_SELF_REF}"); + assert_eq!( + result, "${_TEST_SELF_REF}", + "Should expand once, not infinitely" + ); + std::env::remove_var("_TEST_SELF_REF"); + } + + #[test] + fn test_expand_env_vars_unclosed_brace() { + let result = expand_env_vars("prefix-${UNCLOSED"); + assert_eq!(result, "prefix-${UNCLOSED"); + } + + // ======================================== + // write_mcp_server_config() tests + // ======================================== + + #[test] + fn test_write_mcp_server_config_creates_named_file() { + let dir = tempfile::tempdir().unwrap(); + let entry = serde_json::json!({ + "command": "/usr/bin/test", + "type": "stdio" + }); + let result = write_mcp_server_config(dir.path(), "myserver", entry); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.contains("myserver-mcp.json")); + assert!(std::path::Path::new(&path).exists()); + } + + #[test] + fn test_write_mcp_server_config_json_structure() { + let dir = tempfile::tempdir().unwrap(); + let entry = serde_json::json!({ + "command": "/bin/foo", + "args": ["--bar"], + "env": { "KEY": "val" }, + "type": "stdio" + }); + let path = write_mcp_server_config(dir.path(), "testsvr", entry).unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["testsvr"]["command"].as_str(), + Some("/bin/foo") + ); + assert_eq!( + json["mcpServers"]["testsvr"]["args"][0].as_str(), + Some("--bar") + ); + assert_eq!( + json["mcpServers"]["testsvr"]["env"]["KEY"].as_str(), + Some("val") + ); + } + + // ======================================== + // external_mcp_config_flag() tests + // ======================================== + + #[test] + fn test_external_mcp_config_flag_static_config() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("_TEST_EXT_KEY", "secret123"); + let server = crate::config::ExternalMcpServer { + name: "mytools".to_string(), + command: "/usr/bin/my-mcp".to_string(), + args: vec!["--port".to_string(), "9090".to_string()], + env: std::collections::HashMap::from([( + "API_KEY".to_string(), + "${_TEST_EXT_KEY}".to_string(), + )]), + enabled: true, + discover_from: None, + }; + let result = external_mcp_config_flag(dir.path(), &server, "/project"); + assert!(result.is_some()); + let path = result.unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["mytools"]["command"].as_str(), + Some("/usr/bin/my-mcp") + ); + assert_eq!( + json["mcpServers"]["mytools"]["args"][0].as_str(), + Some("--port") + ); + assert_eq!( + json["mcpServers"]["mytools"]["args"][1].as_str(), + Some("9090") + ); + assert_eq!( + json["mcpServers"]["mytools"]["env"]["API_KEY"].as_str(), + Some("secret123") + ); + std::env::remove_var("_TEST_EXT_KEY"); + } + + #[test] + fn test_external_mcp_config_flag_sidecar_discovery() { + let session_dir = tempfile::tempdir().unwrap(); + let project_dir = tempfile::tempdir().unwrap(); + let sidecar_path = project_dir.path().join(".kanbots"); + std::fs::create_dir_all(&sidecar_path).unwrap(); + let sidecar_file = sidecar_path.join("active-session.json"); + let sidecar_content = serde_json::json!({ + "mcpServer": { + "command": "/usr/bin/node", + "args": ["/path/to/server.js"], + "env": { + "BRIDGE_URL": "http://127.0.0.1:54321", + "BRIDGE_TOKEN": "abc123" + } + }, + "pid": 12345 + }); + std::fs::write( + &sidecar_file, + serde_json::to_string_pretty(&sidecar_content).unwrap(), + ) + .unwrap(); + + let server = crate::config::ExternalMcpServer { + name: "kanbots".to_string(), + command: String::new(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: Some(".kanbots/active-session.json".to_string()), + }; + let result = external_mcp_config_flag( + session_dir.path(), + &server, + &project_dir.path().to_string_lossy(), + ); + assert!(result.is_some()); + let path = result.unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["kanbots"]["command"].as_str(), + Some("/usr/bin/node") + ); + assert_eq!( + json["mcpServers"]["kanbots"]["args"][0].as_str(), + Some("/path/to/server.js") + ); + assert_eq!( + json["mcpServers"]["kanbots"]["env"]["BRIDGE_TOKEN"].as_str(), + Some("abc123") + ); + } + + #[test] + fn test_external_mcp_config_flag_missing_sidecar_empty_command_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let server = crate::config::ExternalMcpServer { + name: "kanbots".to_string(), + command: String::new(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: Some(".kanbots/active-session.json".to_string()), + }; + let result = external_mcp_config_flag(dir.path(), &server, "/nonexistent/project"); + assert!( + result.is_none(), + "Should return None when sidecar missing and command empty" + ); + } + + #[test] + fn test_external_mcp_config_flag_missing_sidecar_static_fallback() { + let dir = tempfile::tempdir().unwrap(); + let server = crate::config::ExternalMcpServer { + name: "kanbots".to_string(), + command: "kanbots-mcp-server".to_string(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: Some(".kanbots/active-session.json".to_string()), + }; + let result = external_mcp_config_flag(dir.path(), &server, "/nonexistent/project"); + assert!( + result.is_some(), + "Should fall back to static command when sidecar missing" + ); + let path = result.unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["kanbots"]["command"].as_str(), + Some("kanbots-mcp-server") + ); + } + + #[test] + fn test_external_mcp_config_flag_disabled_server_not_called() { + // This is tested at the generate_config_flags level, but we verify + // the function itself handles empty command correctly + let dir = tempfile::tempdir().unwrap(); + let server = crate::config::ExternalMcpServer { + name: "disabled".to_string(), + command: String::new(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: None, + }; + let result = external_mcp_config_flag(dir.path(), &server, "/project"); + assert!( + result.is_none(), + "Empty command without discover_from should return None" + ); + } + + // ======================================== + // statusline script + settings tests + // ======================================== + + #[test] + fn test_ensure_statusline_script_creates_executable_file() { + let dir = tempfile::tempdir().unwrap(); + let result = ensure_statusline_script(dir.path()); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.exists()); + assert!(path.to_string_lossy().contains("operator-statusline.sh")); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("#!/usr/bin/env bash")); + assert!(content.contains("OPERATOR_")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o111, 0o111, "Script should be executable"); + } + } + + #[test] + fn test_ensure_statusline_script_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path1 = ensure_statusline_script(dir.path()).unwrap(); + let content1 = std::fs::read_to_string(&path1).unwrap(); + + let path2 = ensure_statusline_script(dir.path()).unwrap(); + let content2 = std::fs::read_to_string(&path2).unwrap(); + + assert_eq!(path1, path2); + assert_eq!(content1, content2); + } + + #[test] + fn test_statusline_settings_flag_creates_valid_json() { + let dir = tempfile::tempdir().unwrap(); + let result = statusline_settings_flag(dir.path()); + assert!(result.is_some()); + let settings_path = result.unwrap(); + assert!(settings_path.contains("operator-settings.json")); + + let content = std::fs::read_to_string(&settings_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["statusLine"]["type"].as_str(), Some("command")); + assert!(json["statusLine"]["command"] + .as_str() + .unwrap() + .contains("operator-statusline.sh")); + } } diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 4a60543..f580ad3 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -7,9 +7,9 @@ mod cmux_session; pub mod interpolation; -mod llm_command; +pub(crate) mod llm_command; mod options; -mod prompt; +pub(crate) mod prompt; mod step_config; mod tmux_session; pub mod worktree_setup; @@ -23,6 +23,8 @@ use std::sync::Arc; use anyhow::{Context, Result}; +use uuid::Uuid; + use crate::agents::cmux::{CmuxClient, SystemCmuxClient}; use crate::agents::tmux::{sanitize_session_name, SystemTmuxClient, TmuxClient, TmuxError}; use crate::agents::zellij::{SystemZellijClient, ZellijClient}; @@ -89,6 +91,8 @@ pub struct PreparedLaunch { pub session_window_ref: Option, /// Session context reference (e.g. cmux workspace, zellij session) pub session_context_ref: Option, + /// Operator environment variables for the terminal session + pub env_vars: std::collections::HashMap, } /// Minimum required tmux version @@ -317,6 +321,26 @@ impl Launcher { initial_prompt: &str, options: &LaunchOptions, ) -> Result<(String, String)> { + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + + // Build operator environment variables for the terminal session + let operator_env = prompt::OperatorEnvVars { + agent_id: agent_id.clone(), + ticket_id: ticket.id.clone(), + project: ticket.project.clone(), + step: if ticket.step.is_empty() { + "initial".to_string() + } else { + ticket.step.clone() + }, + ui_url: format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ui_port: self.config.rest_api.port, + }; + // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -330,6 +354,7 @@ impl Launcher { working_dir_str, initial_prompt, options, + &operator_env, )?; ( result.session_name, @@ -347,6 +372,7 @@ impl Launcher { working_dir_str, initial_prompt, options, + &operator_env, )?; (result.session_name, "zellij", None) } else { @@ -358,6 +384,7 @@ impl Launcher { working_dir_str, initial_prompt, options, + &operator_env, )?; (name, "tmux", None) }; @@ -375,15 +402,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with launch options + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_mode_string()), + None, )?; // Store session name in state for later recovery @@ -499,6 +528,13 @@ impl Launcher { Ok(cap.saturating_sub(running)) } + fn project_available_slots(&self, project: &str) -> Result { + let state = State::load(&self.config)?; + let count = state.project_agent_count(project); + let cap = self.config.effective_max_agents_per_repo(); + Ok(cap.saturating_sub(count)) + } + /// Fan out a `multi_model` step: N delegators, same prompt for all. /// /// Launches up to `available_slots()` sub-agents immediately; any that @@ -685,7 +721,9 @@ impl Launcher { base_options: &LaunchOptions, ) -> Result<()> { loop { - let budget = self.available_slots()?; + let budget = self + .available_slots()? + .min(self.project_available_slots(&ticket.project)?); if budget == 0 { break; } @@ -771,6 +809,9 @@ impl Launcher { // Generate session UUID let session_uuid = generate_session_uuid(); + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + // Get the step name (use "initial" if not set) let step_name = if ticket.step.is_empty() { "initial".to_string() @@ -778,6 +819,24 @@ impl Launcher { ticket.step.clone() }; + // Build operator environment variables HashMap for PreparedLaunch + let mut env_vars = std::collections::HashMap::new(); + env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone()); + env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone()); + env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone()); + env_vars.insert("OPERATOR_STEP".to_string(), step_name.clone()); + env_vars.insert( + "OPERATOR_UI_URL".to_string(), + format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ); + env_vars.insert( + "OPERATOR_UI_PORT".to_string(), + self.config.rest_api.port.to_string(), + ); + // Store the session UUID in the ticket file (now in in-progress) let ticket_in_progress_path = self .config @@ -867,7 +926,7 @@ impl Launcher { // Wrap in docker command if docker mode is enabled if options.docker_mode { - llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?; + llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?; } // Determine tool name from options or default @@ -883,15 +942,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with launch + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_mode_string()), + None, )?; // Store session name in state for later recovery @@ -931,6 +992,7 @@ impl Launcher { session_wrapper: None, session_window_ref: None, session_context_ref: None, + env_vars, }) } @@ -1005,6 +1067,9 @@ impl Launcher { .clone() .unwrap_or_else(generate_session_uuid); + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + // Get the step name (use "initial" if not set) let step_name = if ticket.step.is_empty() { "initial".to_string() @@ -1012,6 +1077,24 @@ impl Launcher { ticket.step.clone() }; + // Build operator environment variables HashMap for PreparedLaunch + let mut env_vars = std::collections::HashMap::new(); + env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone()); + env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone()); + env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone()); + env_vars.insert("OPERATOR_STEP".to_string(), step_name.clone()); + env_vars.insert( + "OPERATOR_UI_URL".to_string(), + format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ); + env_vars.insert( + "OPERATOR_UI_PORT".to_string(), + self.config.rest_api.port.to_string(), + ); + // Store the session UUID in the ticket file let ticket_in_progress_path = self .config @@ -1112,7 +1195,7 @@ impl Launcher { // Wrap in docker command if docker mode is enabled if options.launch_options.docker_mode { - llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?; + llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?; } // Determine tool name from options or default @@ -1129,15 +1212,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with launch + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_options.launch_mode_string()), + None, )?; // Store session name in state for later recovery @@ -1179,6 +1264,7 @@ impl Launcher { session_wrapper: None, session_window_ref: None, session_context_ref: None, + env_vars, }) } @@ -1234,6 +1320,26 @@ impl Launcher { let initial_prompt = generate_prompt(&self.config, &ticket); let initial_prompt = apply_prompt_wrapping(initial_prompt, &options.launch_options); + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + + // Build operator environment variables for the terminal session + let operator_env = prompt::OperatorEnvVars { + agent_id: agent_id.clone(), + ticket_id: ticket.id.clone(), + project: ticket.project.clone(), + step: if ticket.step.is_empty() { + "initial".to_string() + } else { + ticket.step.clone() + }, + ui_url: format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ui_port: self.config.rest_api.port, + }; + // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -1247,6 +1353,7 @@ impl Launcher { &working_dir_str, &initial_prompt, &options, + &operator_env, )?; ( result.session_name, @@ -1264,6 +1371,7 @@ impl Launcher { &working_dir_str, &initial_prompt, &options, + &operator_env, )?; (result.session_name, "zellij", None) } else { @@ -1274,6 +1382,7 @@ impl Launcher { &working_dir_str, &initial_prompt, &options, + &operator_env, )?; (name, "tmux", None) }; @@ -1292,15 +1401,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with new agent + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_options.launch_mode_string()), + None, )?; // Store session name in state for later recovery diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index a8719cc..1651234 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -10,6 +10,42 @@ use crate::config::Config; use crate::queue::Ticket; use crate::templates::{schema::TemplateSchema, TemplateType}; +/// Environment variables injected into operator-spawned agent command scripts +/// for branding (status line, pane title, UI deep-links). +#[derive(Debug, Clone, Default)] +pub struct OperatorEnvVars { + pub agent_id: String, + pub ticket_id: String, + pub project: String, + pub step: String, + pub ui_url: String, + pub ui_port: u16, +} + +impl OperatorEnvVars { + /// Render shell `export` lines for all operator env vars. + pub fn to_export_block(&self) -> String { + format!( + "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\n", + shell_escape(&self.agent_id), + shell_escape(&self.ticket_id), + shell_escape(&self.project), + shell_escape(&self.step), + shell_escape(&self.ui_url), + self.ui_port, + ) + } + + /// Render an OSC 2 escape sequence to set the terminal pane title. + pub fn to_pane_title_line(&self) -> String { + format!( + "printf '\\033]2;[OPR8R] %s | %s\\033\\\\' {} {}\n", + shell_escape(&self.ticket_id), + shell_escape(&self.project), + ) + } +} + /// Generate the initial prompt for a ticket based on its type pub fn generate_prompt(config: &Config, ticket: &Ticket) -> String { let ticket_path = config @@ -156,15 +192,24 @@ pub fn write_command_file( session_uuid: &str, project_path: &str, llm_command: &str, + operator_env: Option<&OperatorEnvVars>, ) -> Result { let commands_dir = config.tickets_path().join("operator/commands"); fs::create_dir_all(&commands_dir).context("Failed to create commands directory")?; let command_file = commands_dir.join(format!("{session_uuid}.sh")); - // Build script content with shebang, cd, and exec + // Build script content with shebang, optional env vars, cd, and exec + let env_block = operator_env + .map(OperatorEnvVars::to_export_block) + .unwrap_or_default(); + + let pane_title = operator_env + .map(OperatorEnvVars::to_pane_title_line) + .unwrap_or_default(); + let script_content = format!( - "#!/bin/bash\ncd {}\nexec {}\n", + "#!/bin/bash\n{env_block}{pane_title}cd {}\nexec {}\n", shell_escape(project_path), llm_command ); @@ -267,7 +312,7 @@ mod tests { let project_path = "/path/to/project"; let llm_command = "claude --session-id abc123 --print-prompt-path /tmp/prompt.txt"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = write_command_file(&config, session_uuid, project_path, llm_command, None); assert!(result.is_ok()); let command_file = result.unwrap(); @@ -291,7 +336,7 @@ mod tests { let project_path = "/path/with spaces/to/project"; let llm_command = "claude --arg value"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = write_command_file(&config, session_uuid, project_path, llm_command, None); assert!(result.is_ok()); let content = std::fs::read_to_string(result.unwrap()).unwrap(); @@ -310,7 +355,7 @@ mod tests { let project_path = "/path/with'quotes/and$dollar"; let llm_command = "claude --arg value"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = write_command_file(&config, session_uuid, project_path, llm_command, None); assert!(result.is_ok()); let content = std::fs::read_to_string(result.unwrap()).unwrap(); @@ -329,7 +374,7 @@ mod tests { let project_path = "/project"; let llm_command = r#"claude --session-id abc --print-prompt-path /tmp/file.txt --add-dir "/dir with spaces" --model sonnet"#; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = write_command_file(&config, session_uuid, project_path, llm_command, None); assert!(result.is_ok()); let content = std::fs::read_to_string(result.unwrap()).unwrap(); @@ -350,7 +395,7 @@ mod tests { let project_path = "/project"; let llm_command = "claude --arg value"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = write_command_file(&config, session_uuid, project_path, llm_command, None); assert!(result.is_ok()); let command_file = result.unwrap(); @@ -360,4 +405,95 @@ mod tests { // Check that the file is executable (0o755 = rwxr-xr-x) assert_eq!(permissions.mode() & 0o777, 0o755); } + + #[test] + fn test_operator_env_vars_to_export_block() { + let env = OperatorEnvVars { + agent_id: "abc-123".to_string(), + ticket_id: "FEAT-042".to_string(), + project: "gamesvc".to_string(), + step: "implement".to_string(), + ui_url: "http://localhost:7007/#/agent/abc-123".to_string(), + ui_port: 7007, + }; + let block = env.to_export_block(); + assert!(block.contains("export OPERATOR_AGENT_ID='abc-123'")); + assert!(block.contains("export OPERATOR_TICKET_ID='FEAT-042'")); + assert!(block.contains("export OPERATOR_PROJECT='gamesvc'")); + assert!(block.contains("export OPERATOR_STEP='implement'")); + assert!(block.contains("export OPERATOR_UI_URL='http://localhost:7007/#/agent/abc-123'")); + assert!(block.contains("export OPERATOR_UI_PORT=7007")); + } + + #[test] + fn test_operator_env_vars_to_pane_title_line() { + let env = OperatorEnvVars { + agent_id: "abc-123".to_string(), + ticket_id: "FEAT-042".to_string(), + project: "gamesvc".to_string(), + step: "implement".to_string(), + ui_url: "http://localhost:7007/#/agent/abc-123".to_string(), + ui_port: 7007, + }; + let line = env.to_pane_title_line(); + assert!(line.contains("\\033]2;")); + assert!(line.contains("FEAT-042")); + assert!(line.contains("gamesvc")); + } + + #[test] + fn test_write_command_file_with_operator_env() { + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp_dir.path()); + + let env = OperatorEnvVars { + agent_id: "test-agent-id".to_string(), + ticket_id: "FEAT-001".to_string(), + project: "myproject".to_string(), + step: "plan".to_string(), + ui_url: "http://localhost:7007/#/agent/test-agent-id".to_string(), + ui_port: 7007, + }; + + let result = write_command_file( + &config, + "test-uuid-env", + "/path/to/project", + "claude --session-id abc123", + Some(&env), + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(result.unwrap()).unwrap(); + assert!(content.contains("export OPERATOR_AGENT_ID='test-agent-id'")); + assert!(content.contains("export OPERATOR_TICKET_ID='FEAT-001'")); + assert!(content.contains("export OPERATOR_PROJECT='myproject'")); + assert!(content.contains("\\033]2;")); + assert!(content.contains("cd '/path/to/project'")); + assert!(content.contains("exec claude --session-id abc123")); + } + + #[test] + fn test_write_command_file_without_operator_env_unchanged() { + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp_dir.path()); + + let result = write_command_file( + &config, + "test-uuid-noenv", + "/path/to/project", + "claude --session-id abc123", + None, + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(result.unwrap()).unwrap(); + assert!(!content.contains("OPERATOR_")); + assert!(!content.contains("\\033]2;")); + assert!(content.starts_with("#!/bin/bash\ncd")); + } } diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 46701fd..78c2786 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -501,9 +501,21 @@ async fn test_launch_global_ticket_uses_root() { // ======================================== use super::options::RelaunchOptions; +use super::prompt::OperatorEnvVars; use super::tmux_session::{launch_in_tmux_with_options, launch_in_tmux_with_relaunch_options}; use crate::agents::tmux::TmuxClient; +fn make_test_operator_env() -> OperatorEnvVars { + OperatorEnvVars { + agent_id: Uuid::new_v4().to_string(), + ticket_id: "TEST-001".to_string(), + project: "test-project".to_string(), + step: "initial".to_string(), + ui_url: "http://localhost:7008/#/agent/test".to_string(), + ui_port: 7008, + } +} + fn make_test_config_with_docker(temp_dir: &TempDir, image: &str) -> Config { let mut config = make_test_config(temp_dir); config.launch.docker.image = image.to_string(); @@ -539,6 +551,7 @@ fn test_launch_in_tmux_session_uses_prefix() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok(), "Launch failed: {:?}", result.err()); @@ -575,6 +588,7 @@ fn test_launch_in_tmux_existing_session_returns_error() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_err()); @@ -607,6 +621,7 @@ fn test_launch_in_tmux_sends_cd_command() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -652,6 +667,7 @@ fn test_launch_in_tmux_sends_llm_command() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -697,6 +713,7 @@ fn test_launch_in_tmux_yolo_mode_applies_flags() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -738,6 +755,7 @@ fn test_launch_in_tmux_yolo_mode_disabled_no_flags() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -779,6 +797,7 @@ fn test_launch_in_tmux_docker_mode_wraps() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -821,6 +840,7 @@ fn test_launch_in_tmux_both_modes() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -884,6 +904,7 @@ fn test_launch_in_tmux_uses_provider_from_options() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -926,6 +947,7 @@ fn test_launch_in_tmux_writes_prompt_file() { &project_path, "Test prompt content", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -968,6 +990,7 @@ fn test_launch_in_tmux_tmux_not_installed() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_err()); @@ -1007,6 +1030,7 @@ fn test_relaunch_fresh_start_new_uuid() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1043,6 +1067,7 @@ fn test_relaunch_inherits_yolo_mode() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1088,6 +1113,7 @@ fn test_relaunch_inherits_docker_mode() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1131,6 +1157,7 @@ fn test_relaunch_existing_session_errors() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_err()); @@ -1182,6 +1209,7 @@ fn test_relaunch_with_resume_adds_flag() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1231,6 +1259,7 @@ fn test_relaunch_missing_prompt_fresh_start() { &project_path, "Fallback prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1310,6 +1339,7 @@ fn test_launch_correct_project_directory_from_ticket() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1389,6 +1419,7 @@ fn test_launch_provider_from_delegator_determines_tool() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1434,6 +1465,7 @@ fn test_launch_yolo_flags_per_tool() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1475,6 +1507,7 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() { let mut config = make_test_config(&temp_dir); config.agents.max_parallel = 10; config.agents.cores_reserved = 0; + config.agents.max_agents_per_repo = 10; add_delegators(&mut config, &["claude-opus", "gemini-pro"]); let mock = Arc::new(MockTmuxClient::new()); @@ -1547,9 +1580,10 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() { async fn test_launch_pending_sub_agents_respects_slot_budget() { let temp_dir = TempDir::new().unwrap(); let mut config = make_test_config(&temp_dir); - // Budget of exactly 1 slot + // Budget of exactly 1 global slot (per-repo cap is high so only max_parallel constrains) config.agents.max_parallel = 1; config.agents.cores_reserved = 0; + config.agents.max_agents_per_repo = 10; add_delegators(&mut config, &["claude-opus", "gemini-pro"]); let mock = Arc::new(MockTmuxClient::new()); diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index 58c1dfb..db0fa55 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -16,7 +16,7 @@ use super::llm_command::{ use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, - write_prompt_file, + write_prompt_file, OperatorEnvVars, }; use super::SESSION_PREFIX; @@ -28,6 +28,7 @@ pub fn launch_in_tmux_with_options( project_path: &str, initial_prompt: &str, options: &LaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Create session name from ticket ID (sanitize for tmux). // For multi-agent fan-out, append the session_suffix to distinguish @@ -180,12 +181,18 @@ pub fn launch_in_tmux_with_options( // Wrap in docker command if docker mode is enabled if options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?; } // Write the command to a shell script file to avoid issues with long commands // and special characters when using tmux send-keys - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { @@ -228,6 +235,7 @@ pub fn launch_in_tmux_with_relaunch_options( project_path: &str, initial_prompt: &str, options: &RelaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Create session name from ticket ID (sanitize for tmux) let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); @@ -404,12 +412,18 @@ pub fn launch_in_tmux_with_relaunch_options( // Wrap in docker command if docker mode is enabled if options.launch_options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?; } // Write the command to a shell script file to avoid issues with long commands // and special characters when using tmux send-keys - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs index 5f93b73..4ee3d01 100644 --- a/src/agents/launcher/worktree_setup.rs +++ b/src/agents/launcher/worktree_setup.rs @@ -273,7 +273,6 @@ async fn detect_default_branch(repo_path: &Path) -> Option { /// * `cleanup_script` - Optional cleanup script to run before removal /// * `prune_branch` - Whether to delete the branch /// * `delete_remote_branch` - Whether to delete the remote branch too -#[allow(dead_code)] // Will be used in sync.rs for PR merge cleanup pub async fn cleanup_ticket_worktree( config: &Config, worktree_path: &Path, diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 009b06c..7456461 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -20,7 +20,7 @@ use super::llm_command::{ use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, - write_prompt_file, + write_prompt_file, OperatorEnvVars, }; /// Result of launching in zellij — includes tab name for state tracking #[derive(Debug, Clone)] @@ -38,6 +38,7 @@ pub fn launch_in_zellij_with_options( project_path: &str, initial_prompt: &str, options: &LaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check zellij is available and we're inside zellij zellij @@ -132,11 +133,17 @@ pub fn launch_in_zellij_with_options( } if options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?; } // Write the command to a shell script file - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { @@ -183,6 +190,7 @@ pub fn launch_in_zellij_with_relaunch_options( project_path: &str, initial_prompt: &str, options: &RelaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check zellij is available and we're inside zellij zellij @@ -292,11 +300,17 @@ pub fn launch_in_zellij_with_relaunch_options( } if options.launch_options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command(config, &llm_cmd, project_path, None)?; } // Write and send command - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + )?; if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", diff --git a/src/agents/mod.rs b/src/agents/mod.rs index e0d6edc..7f2e4a6 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -9,7 +9,7 @@ pub mod delegator_resolution; mod generator; pub mod hooks; pub mod idle_detector; -mod launcher; +pub(crate) mod launcher; mod monitor; mod pr_workflow; mod session; diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index 619ee02..2dc9244 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -396,7 +396,7 @@ impl SessionMonitor { if elapsed >= self.check_interval { Duration::ZERO } else { - self.check_interval - elapsed + self.check_interval.saturating_sub(elapsed) } } } diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 686caa0..d4a0044 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -618,7 +618,7 @@ impl TicketSessionSync { if elapsed >= self.sync_interval { Duration::ZERO } else { - self.sync_interval - elapsed + self.sync_interval.saturating_sub(elapsed) } } diff --git a/src/app/agents.rs b/src/app/agents.rs index 015a428..fee9d3f 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -160,10 +160,11 @@ impl App { // Get selected ticket if let Some(ticket) = self.dashboard.selected_ticket().cloned() { - // Check if project is already busy - if state.is_project_busy(&ticket.project) { + let project_count = state.project_agent_count(&ticket.project); + let max_per_repo = self.config.effective_max_agents_per_repo(); + if project_count >= max_per_repo { self.dashboard.set_status(&format!( - "Cannot launch: {} has an active agent", + "Cannot launch: {} has {project_count}/{max_per_repo} agents", ticket.project )); return Ok(()); @@ -211,9 +212,11 @@ impl App { return Ok(()); }; - if state.is_project_busy(&ticket.project) { + let project_count = state.project_agent_count(&ticket.project); + let max_per_repo = self.config.effective_max_agents_per_repo(); + if project_count >= max_per_repo { self.dashboard.set_status(&format!( - "Cannot launch: {} has an active agent", + "Cannot launch: {} has {project_count}/{max_per_repo} agents", ticket.project )); return Ok(()); diff --git a/src/app/data_sync.rs b/src/app/data_sync.rs index 3279e74..ef19557 100644 --- a/src/app/data_sync.rs +++ b/src/app/data_sync.rs @@ -33,11 +33,6 @@ impl App { .collect(); self.dashboard.update_completed(completed); - // Update Backstage server status - self.backstage_server.refresh_status(); - self.dashboard - .update_backstage_status(self.backstage_server.status()); - // Update wrapper connection status let wrapper_status = self.check_wrapper_connection(); self.dashboard diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 9f5b7b3..9bc6ad8 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -20,12 +20,10 @@ impl App { // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { match code { - KeyCode::Char('i' | 'I') => { - if setup.confirm_selected { - self.initialize_tickets()?; - self.setup_screen = None; - self.refresh_data()?; - } + KeyCode::Char('i' | 'I') if setup.confirm_selected => { + self.initialize_tickets()?; + self.setup_screen = None; + self.refresh_data()?; } KeyCode::Enter => { match setup.confirm() { @@ -225,11 +223,9 @@ impl App { // Session recovery dialog handling if self.session_recovery_dialog.visible { match code { - KeyCode::Char('r' | 'R') => { - if self.session_recovery_dialog.has_session_id() { - self.handle_session_recovery(SessionRecoverySelection::ResumeSession) - .await?; - } + KeyCode::Char('r' | 'R') if self.session_recovery_dialog.has_session_id() => { + self.handle_session_recovery(SessionRecoverySelection::ResumeSession) + .await?; } KeyCode::Char('s' | 'S') => { self.handle_session_recovery(SessionRecoverySelection::StartFresh) @@ -389,9 +385,8 @@ impl App { match code { KeyCode::Char('q') => { // Stop servers if running before exiting - if self.rest_api_server.is_running() || self.backstage_server.is_running() { + if self.rest_api_server.is_running() { self.rest_api_server.stop(); - let _ = self.backstage_server.stop(); } // Shut down PR monitor if let Some(tx) = self.pr_shutdown_tx.take() { @@ -491,7 +486,7 @@ impl App { } } KeyCode::Char('W' | 'w') => { - self.toggle_web_servers(terminal)?; + self.open_web_ui()?; } KeyCode::Char('T' | 't') => { // Open collection switch dialog @@ -530,7 +525,6 @@ impl App { } else { // First Ctrl+C - stop servers and enter confirmation mode self.rest_api_server.stop(); - let _ = self.backstage_server.stop(); self.exit_confirmation_mode = true; self.exit_confirmation_time = Some(std::time::Instant::now()); diff --git a/src/app/mod.rs b/src/app/mod.rs index 90c99c4..39edf1f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,6 @@ use tokio::sync::{mpsc, RwLock}; use crate::agents::tmux::SystemTmuxClient; use crate::agents::{SessionMonitor, TicketSessionSync}; -use crate::backstage::BackstageServer; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; use crate::notifications::NotificationService; @@ -66,8 +65,6 @@ pub struct App { pub(crate) ticket_sync: TicketSessionSync, /// Last sync status message for display pub(crate) sync_status_message: Option, - /// Backstage server lifecycle manager - pub(crate) backstage_server: BackstageServer, /// REST API server lifecycle manager pub(crate) rest_api_server: RestApiServer, /// Exit confirmation mode (first Ctrl+C pressed) @@ -76,6 +73,8 @@ pub struct App { pub(crate) exit_confirmation_time: Option, /// Start web servers on launch (--web flag) pub(crate) start_web_on_launch: bool, + /// Open the embedded web UI in browser on launch (--ui flag) + pub(crate) open_ui_on_launch: bool, /// Session recovery dialog for handling dead tmux sessions pub(crate) session_recovery_dialog: SessionRecoveryDialog, /// Collection switch dialog for changing active issue type collection @@ -118,7 +117,7 @@ pub struct App { } impl App { - pub async fn new(mut config: Config, start_web: bool) -> Result { + pub async fn new(mut config: Config, start_web: bool, open_ui: bool) -> Result { // Run LLM tool detection on first startup if !config.llm_tools.detection_complete { tracing::info!("Detecting LLM CLI tools..."); @@ -212,15 +211,6 @@ impl App { }; let ticket_sync = TicketSessionSync::new(&config, Arc::clone(&tmux_client)); - // Initialize Backstage server lifecycle manager using compiled binary mode - let backstage_server = BackstageServer::with_compiled_binary( - config.state_path(), - config.backstage.release_url.clone(), - config.backstage.local_binary_path.clone(), - config.backstage.port, - ) - .map_err(|e| anyhow::anyhow!("Failed to initialize backstage server: {e}"))?; - // Initialize REST API server lifecycle manager let rest_api_server = RestApiServer::new(config.clone(), config.rest_api.port); @@ -303,11 +293,11 @@ impl App { session_preview: SessionPreview::new(), ticket_sync, sync_status_message: None, - backstage_server, rest_api_server, exit_confirmation_mode: false, exit_confirmation_time: None, start_web_on_launch: start_web, + open_ui_on_launch: open_ui, session_recovery_dialog: SessionRecoveryDialog::new(), collection_dialog: CollectionSwitchDialog::new(), kanban_view: KanbanView::new(), @@ -331,6 +321,7 @@ impl App { }) } + #[allow(clippy::cognitive_complexity)] pub async fn run(&mut self) -> Result<()> { // Reconcile state with actual tmux sessions on startup self.reconcile_sessions()?; @@ -360,23 +351,21 @@ impl App { } } - // Start Backstage web server if --web flag was passed - if self.start_web_on_launch { - if let Err(e) = self.backstage_server.start() { - tracing::error!("Backstage start failed: {}", e); + // Start web servers if --web flag was passed + if self.start_web_on_launch && self.rest_api_server.is_running() { + let port = self.config.rest_api.port; + let url = format!("http://localhost:{port}/"); + if let Err(e) = status_actions::open_in_browser(&url) { + tracing::warn!("Failed to open web UI: {}", e); } - // Wait for server to be ready then open browser - if self.backstage_server.is_running() { - match self.backstage_server.wait_for_ready(25000) { - Ok(()) => { - if let Err(e) = self.backstage_server.open_browser() { - tracing::warn!("Failed to open browser: {}", e); - } - } - Err(e) => { - tracing::error!("Server not ready: {}", e); - } - } + } + + // Open embedded web UI in browser if --ui flag was passed + if self.open_ui_on_launch && self.rest_api_server.is_running() { + let port = self.config.rest_api.port; + let url = format!("http://localhost:{port}/"); + if let Err(e) = status_actions::open_in_browser(&url) { + tracing::warn!("Failed to open web UI: {}", e); } } @@ -396,6 +385,14 @@ impl App { // Update dashboard with server statuses and exit confirmation mode self.dashboard .update_rest_api_status(self.rest_api_server.status()); + // MCP session count — try_lock so we never block the UI tick; + // a contended lock falls back to the previous frame's count. + let mcp_sessions = self + .rest_api_server + .api_state() + .and_then(|s| s.mcp_sessions.try_lock().ok().map(|m| m.len())) + .unwrap_or(0); + self.dashboard.update_mcp_active_sessions(mcp_sessions); self.dashboard .update_exit_confirmation_mode(self.exit_confirmation_mode); diff --git a/src/app/status_actions.rs b/src/app/status_actions.rs index 1a818df..a718a25 100644 --- a/src/app/status_actions.rs +++ b/src/app/status_actions.rs @@ -1,12 +1,49 @@ use anyhow::Result; use crate::config::SessionWrapperType; +use crate::rest::web_ui::EmbeddedUiState; use crate::ui::status_panel::StatusAction; use crate::ui::with_suspended_tui; use super::git_onboarding; use super::{App, AppTerminal}; +/// Decision from `decide_open_web_ui`: either open a URL or surface a status +/// message explaining why we can't. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum WebUiOutcome { + Open(String), + StatusOnly(String), +} + +/// Pure decision logic for "user pressed `w` / clicked Open Web UI". +/// +/// Kept free of `&self` so it can be unit-tested without spinning up an `App`. +/// Callers resolve the inputs from runtime state and act on the returned +/// outcome (open the URL or just show the status message). +pub(super) fn decide_open_web_ui( + api_running: bool, + url: &str, + state: EmbeddedUiState, +) -> WebUiOutcome { + if !api_running { + return WebUiOutcome::StatusOnly( + "API not running — press Enter on the Operator API row to start it.".into(), + ); + } + match state { + EmbeddedUiState::Ready => WebUiOutcome::Open(url.to_string()), + EmbeddedUiState::Placeholder => WebUiOutcome::StatusOnly( + "Web UI placeholder detected — run `cd ui && bun run build` and rebuild operator." + .into(), + ), + EmbeddedUiState::Missing => WebUiOutcome::StatusOnly( + "Binary built without `embed-ui` feature — rebuild with `cargo build` (default) or `--features embed-ui`." + .into(), + ), + } +} + /// Open a URL in the default browser. pub(super) fn open_in_browser(url: &str) -> std::io::Result<()> { let opener = if cfg!(target_os = "macos") { @@ -83,8 +120,13 @@ impl App { StatusAction::RestartWrapperConnection => { self.restart_wrapper_connection(); } - StatusAction::ToggleWebServers => { - self.toggle_web_servers(terminal)?; + StatusAction::OpenWebUi { port } => { + let url = format!("http://localhost:{port}/"); + self.try_open_web_ui(&url); + } + StatusAction::OpenWebUiAt { port, route } => { + let url = format!("http://localhost:{port}/#{route}"); + self.try_open_web_ui(&url); } StatusAction::SetDefaultLlm { tool_name, model } => { self.set_default_llm(&tool_name, &model); @@ -181,6 +223,103 @@ impl App { .set_status(&format!("Failed to reload config: {e}")); } }, + StatusAction::ToggleMcpHttp => { + self.config.mcp.http_enabled = !self.config.mcp.http_enabled; + self.dashboard.update_config(&self.config); + self.dashboard.set_status(if self.config.mcp.http_enabled { + "MCP HTTP enabled — restart the API to mount routes" + } else { + "MCP HTTP disabled — restart the API to unmount routes" + }); + } + StatusAction::WriteAndOpenMcpClientConfig { client } => { + let cwd = std::env::current_dir().unwrap_or_default(); + let Some(snippet) = crate::mcp::client_configs::snippet_for(&client, &cwd) else { + self.dashboard + .set_status(&format!("Unknown MCP client: {client}")); + return Ok(()); + }; + let dir = self.config.tickets_path().join("operator/mcp"); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dashboard + .set_status(&format!("Failed to create {}: {e}", dir.display())); + return Ok(()); + } + let path = dir.join(format!("{client}.json")); + let body = serde_json::to_string_pretty(&snippet).unwrap_or_default(); + if let Err(e) = std::fs::write(&path, body) { + self.dashboard + .set_status(&format!("Failed to write {}: {e}", path.display())); + return Ok(()); + } + let cmd = self.dashboard.editor_config.file_editor().to_string(); + with_suspended_tui(terminal, || { + let (prog, args) = crate::editors::EditorConfig::split_command(&cmd); + let result = std::process::Command::new(prog) + .args(&args) + .arg(&path) + .status(); + if let Err(e) = result { + tracing::warn!("Failed to open editor: {}", e); + } + Ok(()) + })?; + } + StatusAction::OpenMcpDocs => { + if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") { + self.dashboard + .set_status(&format!("Failed to open MCP docs: {e}")); + } + } + StatusAction::WriteAndOpenAcpEditorConfig { editor } => { + let Some(snippet) = crate::acp::client_configs::snippet_for(&editor) else { + self.dashboard + .set_status(&format!("Unknown ACP editor: {editor}")); + return Ok(()); + }; + let dir = self.config.tickets_path().join("operator/acp"); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dashboard + .set_status(&format!("Failed to create {}: {e}", dir.display())); + return Ok(()); + } + // Text-format editors (emacs elisp, kiro TOML) deserialise into + // a JSON string; everything else is a structured Value. + let (extension, body) = match snippet { + serde_json::Value::String(s) => { + let ext = if editor == "emacs" { "el" } else { "toml" }; + (ext, s) + } + other => ( + "json", + serde_json::to_string_pretty(&other).unwrap_or_default(), + ), + }; + let path = dir.join(format!("{editor}.{extension}")); + if let Err(e) = std::fs::write(&path, body) { + self.dashboard + .set_status(&format!("Failed to write {}: {e}", path.display())); + return Ok(()); + } + let cmd = self.dashboard.editor_config.file_editor().to_string(); + with_suspended_tui(terminal, || { + let (prog, args) = crate::editors::EditorConfig::split_command(&cmd); + let result = std::process::Command::new(prog) + .args(&args) + .arg(&path) + .status(); + if let Err(e) = result { + tracing::warn!("Failed to open editor: {}", e); + } + Ok(()) + })?; + } + StatusAction::OpenAcpDocs => { + if let Err(e) = open_in_browser("https://operator.untra.io/acp/") { + self.dashboard + .set_status(&format!("Failed to open ACP docs: {e}")); + } + } StatusAction::None => {} } Ok(()) @@ -245,49 +384,92 @@ impl App { .set_status(&format!("Default LLM set to {tool_name}:{model}")); } - /// Toggle both REST API and Backstage servers together. - pub(super) fn toggle_web_servers(&mut self, terminal: &mut AppTerminal) -> Result<()> { - let backstage_running = self.backstage_server.is_running(); - let rest_running = self.rest_api_server.is_running(); + /// Open the embedded web UI in the default browser. + pub(super) fn open_web_ui(&mut self) -> Result<()> { + let port = self.config.rest_api.port; + let url = format!("http://localhost:{port}/"); + self.try_open_web_ui(&url); + Ok(()) + } + + /// Shared implementation: consult `decide_open_web_ui`, either spawn the + /// browser or surface a status message explaining the failure. + fn try_open_web_ui(&mut self, url: &str) { + let outcome = decide_open_web_ui( + self.rest_api_server.is_running(), + url, + crate::rest::web_ui::embedded_ui_state(), + ); + match outcome { + WebUiOutcome::Open(url) => match open_in_browser(&url) { + Ok(()) => self + .dashboard + .set_status(&format!("Opened web UI at {url}")), + Err(e) => self + .dashboard + .set_status(&format!("Failed to open browser: {e}")), + }, + WebUiOutcome::StatusOnly(msg) => self.dashboard.set_status(&msg), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; - if backstage_running && rest_running { - // Both running - stop both - self.rest_api_server.stop(); - if let Err(e) = self.backstage_server.stop() { - tracing::error!("Backstage stop failed: {}", e); + const URL: &str = "http://localhost:7008/"; + + #[test] + fn test_decide_open_web_ui_api_stopped_returns_status_message() { + let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Ready); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("API not running"), "got: {msg}"); } - } else { - // Show yellow "Starting" indicator immediately for feedback - use crate::backstage::ServerStatus; - self.dashboard - .update_backstage_status(ServerStatus::Starting); - terminal.draw(|f| self.dashboard.render(f))?; + other => panic!("expected StatusOnly, got {other:?}"), + } + } - // Start both if not running - if !rest_running { - if let Err(e) = self.rest_api_server.start() { - tracing::error!("REST API start failed: {}", e); - } + #[test] + fn test_decide_open_web_ui_ready_returns_url() { + let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Ready); + assert_eq!(outcome, WebUiOutcome::Open(URL.to_string())); + } + + #[test] + fn test_decide_open_web_ui_placeholder_warns_user() { + let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Placeholder); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("placeholder"), "got: {msg}"); + assert!(msg.contains("bun run build"), "got: {msg}"); } - if !backstage_running { - if let Err(e) = self.backstage_server.start() { - tracing::error!("Backstage start failed: {}", e); - } + other => panic!("expected StatusOnly, got {other:?}"), + } + } + + #[test] + fn test_decide_open_web_ui_missing_warns_user() { + let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Missing); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("embed-ui"), "got: {msg}"); } - // Wait for server to be ready before opening browser - if self.backstage_server.is_running() { - match self.backstage_server.wait_for_ready(25000) { - Ok(()) => { - if let Err(e) = self.backstage_server.open_browser() { - tracing::warn!("Failed to open browser: {}", e); - } - } - Err(e) => { - tracing::error!("Server not ready: {}", e); - } - } + other => panic!("expected StatusOnly, got {other:?}"), + } + } + + #[test] + fn test_decide_open_web_ui_api_stopped_takes_precedence_over_missing() { + // Even if the UI is missing, the user's first problem to solve is + // starting the API — surface that message, not the embed-ui one. + let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Missing); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("API not running"), "got: {msg}"); } + other => panic!("expected StatusOnly, got {other:?}"), } - Ok(()) } } diff --git a/src/app/tests.rs b/src/app/tests.rs index 006d458..bd3a307 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -247,14 +247,16 @@ mod launch_validation { let dashboard_paused = state.paused; let running_count = state.running_agents().len(); let max_agents = config.effective_max_agents(); - let project_busy = state.is_project_busy("test-project"); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); // All conditions for launch should be met - let can_launch = !dashboard_paused && running_count < max_agents && !project_busy; + let can_launch = + !dashboard_paused && running_count < max_agents && project_count < max_per_repo; assert!( can_launch, - "Should be allowed to launch when not paused, under max, and project not busy" + "Should be allowed to launch when not paused, under max, and project under cap" ); } @@ -303,7 +305,7 @@ mod launch_validation { } #[test] - fn test_try_launch_blocked_project_busy() { + fn test_try_launch_blocked_project_at_capacity() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); @@ -318,22 +320,87 @@ mod launch_validation { ) .unwrap(); - // Check if project is busy let state = State::load(&config).unwrap(); - let project_busy = state.is_project_busy("test-project"); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); - assert!(project_busy, "Project should be busy with running agent"); + assert!( + project_count >= max_per_repo, + "Project should be at capacity with running agent" + ); } #[test] - fn test_try_launch_project_not_busy_when_empty() { + fn test_try_launch_project_empty() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let state = State::load(&config).unwrap(); - let project_busy = state.is_project_busy("test-project"); + let project_count = state.project_agent_count("test-project"); + + assert_eq!(project_count, 0, "Project should have no agents"); + } + + #[test] + fn test_try_launch_allowed_when_under_per_repo_cap() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_agents_per_repo = 2; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); + + assert_eq!(project_count, 1); + assert_eq!(max_per_repo, 2); + assert!( + project_count < max_per_repo, + "Should allow second agent when cap is 2" + ); + } + + #[test] + fn test_try_launch_blocked_at_per_repo_cap() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_agents_per_repo = 2; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "TASK-002".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); - assert!(!project_busy, "Project should not be busy without agents"); + assert!( + project_count >= max_per_repo, + "Should block third agent when cap is 2" + ); } #[test] @@ -939,3 +1006,670 @@ mod agent_switches { ); } } + +// ============================================ +// Ticket Creation Tests +// ============================================ + +mod ticket_creation { + use super::*; + use crate::queue::TicketCreator; + use crate::templates::TemplateType; + use std::collections::HashMap; + + #[test] + fn test_headless_ticket_creation_writes_file_to_queue() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + values.insert("summary".to_string(), "Add user auth".to_string()); + + let result = creator.create_ticket_headless(TemplateType::Task, &values); + assert!(result.is_ok(), "Ticket creation should succeed"); + + let filepath = result.unwrap(); + assert!(filepath.exists(), "Ticket file should exist on disk"); + assert!( + filepath.to_string_lossy().contains("queue"), + "Ticket should be created in the queue directory" + ); + } + + #[test] + fn test_headless_ticket_filename_contains_type_and_project() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + + let filepath = creator + .create_ticket_headless(TemplateType::Feature, &values) + .unwrap(); + let filename = filepath.file_name().unwrap().to_string_lossy(); + + assert!( + filename.contains("FEAT"), + "Filename should contain the ticket type: {filename}" + ); + assert!( + filename.contains("test-project"), + "Filename should contain the project: {filename}" + ); + } + + #[test] + fn test_headless_ticket_defaults_project_to_global() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let values = HashMap::new(); // No project specified + + let filepath = creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + let filename = filepath.file_name().unwrap().to_string_lossy(); + + assert!( + filename.contains("global"), + "Filename should default to 'global' when no project specified: {filename}" + ); + } + + #[test] + fn test_headless_ticket_content_is_not_empty() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + + let filepath = creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + let content = std::fs::read_to_string(&filepath).unwrap(); + + assert!(!content.is_empty(), "Ticket file should have content"); + } + + #[test] + fn test_created_ticket_appears_in_queue_listing() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + values.insert("summary".to_string(), "Test listing".to_string()); + + creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_queue().unwrap(); + + assert_eq!(tickets.len(), 1, "Queue should have the created ticket"); + assert_eq!(tickets[0].ticket_type, "TASK"); + } + + #[test] + fn test_multiple_ticket_types_created_and_sorted_by_priority() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create tickets of different types + let types_and_projects = [ + (TemplateType::Feature, "test-project"), + (TemplateType::Fix, "test-project"), + (TemplateType::Task, "test-project"), + ]; + + for (template_type, project) in &types_and_projects { + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), project.to_string()); + creator + .create_ticket_headless(*template_type, &values) + .unwrap(); + } + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 3, "Queue should have 3 tickets"); + } +} + +// ============================================ +// Ticket Directory Initialization Tests +// ============================================ + +mod ticket_initialization { + use super::*; + + #[test] + fn test_queue_directories_exist_after_config_setup() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let tickets_path = config.tickets_path(); + assert!( + tickets_path.join("queue").exists(), + "queue directory should exist" + ); + assert!( + tickets_path.join("in-progress").exists(), + "in-progress directory should exist" + ); + assert!( + tickets_path.join("completed").exists(), + "completed directory should exist" + ); + assert!( + tickets_path.join("operator").exists(), + "operator directory should exist" + ); + } + + #[test] + fn test_queue_starts_empty() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let queue = Queue::new(&config).unwrap(); + assert!(queue.list_queue().unwrap().is_empty()); + assert!(queue.list_in_progress().unwrap().is_empty()); + assert!(queue.list_completed().unwrap().is_empty()); + } + + #[test] + fn test_claim_ticket_moves_to_in_progress() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create a ticket file in the queue + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + let ticket_filename = "20241225-1200-TASK-test-project-test.md"; + let queue_path = config.tickets_path().join("queue").join(ticket_filename); + std::fs::write(&queue_path, ticket_content).unwrap(); + + // Verify it's in the queue + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_queue().unwrap(); + assert_eq!(tickets.len(), 1); + + // Claim (move to in-progress) + queue.claim_ticket(&tickets[0]).unwrap(); + + // Verify moved + assert!(queue.list_queue().unwrap().is_empty()); + assert_eq!(queue.list_in_progress().unwrap().len(), 1); + } +} + +// ============================================ +// Auto-Launch Decision Logic Tests +// ============================================ + +mod auto_launch_logic { + use super::*; + + fn can_auto_launch(config: &Config, state: &State, queue: &Queue) -> bool { + if state.paused { + return false; + } + let running = state.running_agents().len(); + let max = config.effective_max_agents(); + if running >= max { + return false; + } + let tickets = queue.list_by_priority().unwrap_or_default(); + if tickets.is_empty() { + return false; + } + // Check per-repo cap for the top ticket's project + let top = &tickets[0]; + let project_count = state.project_agent_count(&top.project); + let max_per_repo = config.effective_max_agents_per_repo(); + project_count < max_per_repo + } + + #[test] + fn test_auto_launch_blocked_when_paused() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state.set_paused(true).unwrap(); + let state = State::load(&config).unwrap(); + + // Add a ticket to queue + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-test.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch when paused" + ); + } + + #[test] + fn test_auto_launch_blocked_when_queue_empty() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let state = State::load(&config).unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch with empty queue" + ); + } + + #[test] + fn test_auto_launch_blocked_at_max_agents() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_parallel = 1; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + let state = State::load(&config).unwrap(); + + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-launch.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch when at max agents" + ); + } + + #[test] + fn test_auto_launch_allowed_when_conditions_met() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let state = State::load(&config).unwrap(); + + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-go.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + can_auto_launch(&config, &state, &queue), + "Should auto-launch when not paused, under capacity, with tickets" + ); + } + + #[test] + fn test_auto_launch_blocked_when_project_at_per_repo_cap() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_parallel = 5; + config.agents.max_agents_per_repo = 1; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-existing".to_string(), + "TASK".to_string(), + "testproject".to_string(), + false, + ) + .unwrap(); + let state = State::load(&config).unwrap(); + + // Project name in filename must be [a-z0-9]+ (no hyphens) to match parser regex + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-testproject-blocked.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch when project is at per-repo capacity" + ); + } +} + +// ============================================ +// Status Action Tests +// ============================================ + +mod status_actions { + use crate::ui::status_panel::StatusAction; + + #[test] + fn test_status_action_none_is_noop() { + let action = StatusAction::None; + assert!(matches!(action, StatusAction::None)); + } + + #[test] + fn test_status_action_toggle_section_carries_id() { + use crate::ui::status_panel::SectionId; + let action = StatusAction::ToggleSection(SectionId::Configuration); + if let StatusAction::ToggleSection(id) = action { + assert!(matches!(id, SectionId::Configuration)); + } else { + panic!("Expected ToggleSection"); + } + } + + #[test] + fn test_open_in_browser_constructs_url() { + let port = 8080; + let url = format!("http://localhost:{port}/swagger-ui/"); + assert_eq!(url, "http://localhost:8080/swagger-ui/"); + } +} + +// ============================================ +// Queue Priority Ordering Tests +// ============================================ + +mod queue_priority { + use super::*; + + #[test] + fn test_priority_ordering_inv_before_feat() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create tickets with different types + // INV should sort before FEAT per priority_order config + let feat_content = "---\npriority: P2-medium\n---\n# Feature\n\nContent\n"; + let inv_content = "---\npriority: P0-critical\n---\n# Investigation\n\nContent\n"; + + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-FEAT-test-project-feat.md"), + feat_content, + ) + .unwrap(); + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1201-INV-test-project-inv.md"), + inv_content, + ) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 2); + // INV has higher priority (lower index) in default priority_order + let first_type = &tickets[0].ticket_type; + let second_type = &tickets[1].ticket_type; + + let first_idx = config.priority_index(first_type); + let second_idx = config.priority_index(second_type); + assert!( + first_idx <= second_idx, + "First ticket type '{first_type}' (idx {first_idx}) should have equal or higher priority than '{second_type}' (idx {second_idx})" + ); + } + + #[test] + fn test_same_type_tickets_sorted_fifo_by_timestamp() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let content = "---\npriority: P2-medium\n---\n# Task\n\nContent\n"; + + // Earlier timestamp first + std::fs::write( + config + .tickets_path() + .join("queue/20241225-0800-TASK-test-project-early.md"), + content, + ) + .unwrap(); + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1400-TASK-test-project-late.md"), + content, + ) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 2); + assert!( + tickets[0].timestamp < tickets[1].timestamp, + "Earlier timestamp should sort first: {} vs {}", + tickets[0].timestamp, + tickets[1].timestamp + ); + } + + #[test] + fn test_unknown_ticket_type_sorts_last() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let task_content = "---\npriority: P2-medium\n---\n# Task\n\nContent\n"; + let unknown_content = "---\npriority: P2-medium\n---\n# Custom\n\nContent\n"; + + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-task.md"), + task_content, + ) + .unwrap(); + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-CUSTOM-test-project-custom.md"), + unknown_content, + ) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 2); + // TASK is in priority_order, CUSTOM is not (sorts to usize::MAX) + assert_eq!( + tickets[0].ticket_type, "TASK", + "Known type should sort before unknown" + ); + } +} + +// ============================================ +// Agent State Lifecycle Tests +// ============================================ + +mod agent_lifecycle { + use super::*; + + #[test] + fn test_agent_added_with_correct_metadata() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "FEAT-042".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + let agent = state + .agents + .iter() + .find(|a| a.id == agent_id) + .expect("Agent should exist"); + + assert_eq!(agent.ticket_id, "FEAT-042"); + assert_eq!(agent.ticket_type, "FEAT"); + assert_eq!(agent.project, "test-project"); + } + + #[test] + fn test_multiple_agents_tracked_independently() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let id1 = state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "project-a".to_string(), + false, + ) + .unwrap(); + let id2 = state + .add_agent( + "FEAT-002".to_string(), + "FEAT".to_string(), + "project-b".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.running_agents().len(), 2); + assert_ne!(id1, id2, "Agent IDs should be unique"); + } + + #[test] + fn test_project_agent_count_tracks_per_project() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "project-a".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "TASK-002".to_string(), + "TASK".to_string(), + "project-a".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "TASK-003".to_string(), + "TASK".to_string(), + "project-b".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.project_agent_count("project-a"), 2); + assert_eq!(state.project_agent_count("project-b"), 1); + assert_eq!(state.project_agent_count("project-c"), 0); + } + + #[test] + fn test_agent_session_update_and_lookup() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "TASK-session".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state + .update_agent_session(&agent_id, "op-TASK-session") + .unwrap(); + + let state = State::load(&config).unwrap(); + let agent = state.agent_by_session("op-TASK-session"); + assert!(agent.is_some(), "Should find agent by session name"); + assert_eq!(agent.unwrap().ticket_id, "TASK-session"); + } + + #[test] + fn test_remove_agent_by_session_cleans_state() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "TASK-remove".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .update_agent_session(&agent_id, "op-TASK-remove") + .unwrap(); + + assert_eq!(state.running_agents().len(), 1); + + let mut state = State::load(&config).unwrap(); + state.remove_agent_by_session("op-TASK-remove").unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.running_agents().len(), 0); + assert!(state.agent_by_session("op-TASK-remove").is_none()); + } +} diff --git a/src/app/tickets.rs b/src/app/tickets.rs index f4797ec..fa3d1cd 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -3,7 +3,6 @@ use std::fs; use crate::agents::{generate_status_script, generate_tmux_conf}; use crate::agents::{AgentTicketCreator, AssessTicketCreator}; -use crate::backstage::scaffold::{BackstageScaffold, ScaffoldOptions}; use crate::queue::TicketCreator; use crate::setup::filter_schema_fields; use crate::state::State; @@ -171,26 +170,6 @@ impl App { } } - // Generate Backstage scaffold - let backstage_path = self.config.backstage_path(); - if !BackstageScaffold::exists(&backstage_path) { - let options = ScaffoldOptions::from_config(&self.config); - let scaffold = BackstageScaffold::new(backstage_path, options); - match scaffold.generate() { - Ok(result) => { - tracing::info!( - created = result.created.len(), - skipped = result.skipped.len(), - "Generated Backstage scaffold: {}", - result.summary() - ); - } - Err(e) => { - tracing::warn!("Failed to generate Backstage scaffold: {}", e); - } - } - } - Ok(()) } @@ -297,7 +276,7 @@ impl App { return Ok(()); } - // Create ASSESS ticket for Backstage catalog assessment + // Create ASSESS ticket for catalog assessment let ticket_result = AssessTicketCreator::create_assess_ticket( &result.project_path, &result.project, diff --git a/src/backstage/branding.rs b/src/backstage/branding.rs deleted file mode 100644 index 920bb15..0000000 --- a/src/backstage/branding.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Static branding defaults for Backstage scaffold. -//! -//! Provides default branding configuration and assets. Users can edit -//! generated files manually after scaffold to customize branding. - -#![allow(dead_code)] - -use serde::{Deserialize, Serialize}; - -/// Default branding configuration for Backstage app-config. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BrandingDefaults { - /// Portal title displayed in header - pub title: String, - /// Subtitle shown below title - pub subtitle: String, - /// Primary brand color (hex) - pub primary_color: String, - /// Secondary brand color (hex) - pub secondary_color: String, - /// Optional logo path relative to branding directory - pub logo_path: Option, - /// Optional favicon path relative to branding directory - pub favicon_path: Option, -} - -impl Default for BrandingDefaults { - fn default() -> Self { - Self { - title: "Developer Portal".to_string(), - subtitle: "Powered by Backstage".to_string(), - primary_color: "#0052CC".to_string(), // Backstage blue - secondary_color: "#172B4D".to_string(), - logo_path: Some("logo.svg".to_string()), - favicon_path: None, - } - } -} - -impl BrandingDefaults { - /// Create branding with a custom portal name. - pub fn with_name(name: &str) -> Self { - Self { - title: name.to_string(), - ..Default::default() - } - } - - /// Generate the app section YAML for branding. - pub fn to_app_config_yaml(&self) -> String { - format!( - r#"app: - title: {} - branding: - theme: - light: - primaryColor: "{}" - secondaryColor: "{}" - dark: - primaryColor: "{}" - secondaryColor: "{}""#, - self.title, - self.primary_color, - self.secondary_color, - self.primary_color, - self.secondary_color - ) - } -} - -/// Static branding assets embedded in the binary. -pub struct BrandingAssets; - -impl BrandingAssets { - /// Default logo SVG content - a simple developer portal icon. - pub fn default_logo_svg() -> &'static str { - r##" - - DP -"## - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_branding_defaults() { - let branding = BrandingDefaults::default(); - assert_eq!(branding.title, "Developer Portal"); - assert!(branding.primary_color.starts_with('#')); - assert!(branding.secondary_color.starts_with('#')); - } - - #[test] - fn test_branding_with_name() { - let branding = BrandingDefaults::with_name("GBQR Portal"); - assert_eq!(branding.title, "GBQR Portal"); - assert_eq!(branding.subtitle, "Powered by Backstage"); - } - - #[test] - fn test_app_config_yaml() { - let branding = BrandingDefaults::default(); - let yaml = branding.to_app_config_yaml(); - assert!(yaml.contains("Developer Portal")); - assert!(yaml.contains("#0052CC")); - assert!(yaml.contains("primaryColor")); - } - - #[test] - fn test_default_logo_svg() { - let svg = BrandingAssets::default_logo_svg(); - assert!(svg.contains("")); - assert!(svg.contains("#0052CC")); - } -} diff --git a/src/backstage/mod.rs b/src/backstage/mod.rs deleted file mode 100644 index 18eceb8..0000000 --- a/src/backstage/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Backstage integration for project cataloging and taxonomy management. -//! -//! This module provides: -//! - A 24-Kind project taxonomy (source of truth in `taxonomy.toml`) -//! - catalog-info.yaml generation for Backstage catalog -//! - Backstage scaffold generator for local deployment -//! - Static branding defaults -//! - Project analysis and Kind detection -//! - Bun-based server lifecycle management -//! - Compiled binary runtime management - -pub mod analyzer; -pub mod branding; -pub mod runtime; -pub mod scaffold; -pub mod server; -pub mod taxonomy; - -// Re-exports for TUI integration and testing (used by main.rs binary) -#[allow(unused_imports)] -pub use server::{BackstageServer, ServerStatus}; - -// Runtime management re-exports (public API for future use) -#[allow(unused_imports)] -pub use runtime::{BackstageRuntime, Platform, RuntimeError}; - -// Additional re-exports for future use -#[allow(unused_imports)] -pub use server::{ - copy_default_logo, generate_branding_config, BackstageError, BunClient, BunVersion, - RuntimeBinaryClient, SystemBunClient, -}; diff --git a/src/backstage/runtime.rs b/src/backstage/runtime.rs deleted file mode 100644 index 6536465..0000000 --- a/src/backstage/runtime.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Backstage runtime binary management. -//! -//! Downloads and manages the pre-compiled backstage-server binary -//! for the current platform. - -use std::fs; -use std::path::PathBuf; - -/// Supported platforms for backstage-server binary. -/// -/// Variants are constructed via compile-time cfg attributes in `Platform::current()`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] // Variants are selected at compile time via cfg attributes -pub enum Platform { - DarwinArm64, - DarwinX64, - LinuxX64, - LinuxArm64, -} - -impl Platform { - /// Detect the current platform at compile time. - pub fn current() -> Option { - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - return Some(Platform::DarwinArm64); - - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - return Some(Platform::DarwinX64); - - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - return Some(Platform::LinuxX64); - - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - return Some(Platform::LinuxArm64); - - #[cfg(not(any( - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "macos", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - )))] - return None; - } - - /// Get the Bun target identifier for this platform. - pub fn bun_target(&self) -> &'static str { - match self { - Platform::DarwinArm64 => "bun-darwin-arm64", - Platform::DarwinX64 => "bun-darwin-x64", - Platform::LinuxX64 => "bun-linux-x64", - Platform::LinuxArm64 => "bun-linux-arm64", - } - } - - /// Get a human-readable name for this platform. - pub fn display_name(&self) -> &'static str { - match self { - Platform::DarwinArm64 => "macOS ARM64", - Platform::DarwinX64 => "macOS x64", - Platform::LinuxX64 => "Linux x64", - Platform::LinuxArm64 => "Linux ARM64", - } - } -} - -/// Manages the backstage-server binary lifecycle. -pub struct BackstageRuntime { - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - platform: Platform, -} - -/// Error types for runtime operations. -#[derive(Debug, thiserror::Error)] -pub enum RuntimeError { - #[error("Unsupported platform - backstage-server is not available for this OS/architecture")] - UnsupportedPlatform, - - #[error("Failed to download binary: {0}")] - DownloadFailed(String), - - #[error("Failed to write binary: {0}")] - WriteFailed(String), - - #[error("Binary not found at {0}")] - #[allow(dead_code)] // Reserved for future validation - BinaryNotFound(PathBuf), - - #[error("Local file not found: {0}")] - LocalFileNotFound(PathBuf), - - #[error("Local file is not executable: {0}")] - #[allow(dead_code)] // Only used on Unix platforms - LocalFileNotExecutable(PathBuf), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), -} - -impl BackstageRuntime { - /// Create a new runtime manager. - /// - /// # Arguments - /// * `state_path` - Directory to store the binary (e.g., .tickets/operator) - /// * `release_url` - Base URL for downloading binaries - /// * `local_binary_path` - Optional local path to binary (takes precedence over URL) - pub fn new( - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - ) -> Result { - let platform = Platform::current().ok_or(RuntimeError::UnsupportedPlatform)?; - - Ok(Self { - state_path, - release_url, - local_binary_path: local_binary_path.map(PathBuf::from), - platform, - }) - } - - /// Get the path where the binary should be stored. - pub fn binary_path(&self) -> PathBuf { - self.state_path.join("bin").join("backstage-server") - } - - /// Check if the binary exists. - pub fn binary_exists(&self) -> bool { - self.binary_path().exists() - } - - /// Get the current platform. - pub fn platform(&self) -> Platform { - self.platform - } - - /// Ensure the binary is available, downloading if necessary. - /// - /// Returns the path to the binary. - pub fn ensure_binary(&self) -> Result { - let binary_path = self.binary_path(); - - if binary_path.exists() { - tracing::debug!( - "Backstage binary already exists at {}", - binary_path.display() - ); - return Ok(binary_path); - } - - self.download_binary()?; - Ok(binary_path) - } - - /// Download or copy the binary for the current platform. - /// - /// If `local_binary_path` is set, copies from local path. - /// Otherwise, downloads from `release_url` with platform suffix appended. - fn download_binary(&self) -> Result<(), RuntimeError> { - // Create bin directory - let bin_dir = self.state_path.join("bin"); - fs::create_dir_all(&bin_dir)?; - - let binary_path = self.binary_path(); - - if let Some(ref local_path) = self.local_binary_path { - self.copy_local_binary(local_path, &binary_path)?; - } else { - self.download_remote_binary(&binary_path)?; - } - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&binary_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&binary_path, perms)?; - } - - Ok(()) - } - - /// Copy binary from a local path. - fn copy_local_binary( - &self, - source_path: &PathBuf, - dest_path: &PathBuf, - ) -> Result<(), RuntimeError> { - // Verify source exists - if !source_path.exists() { - return Err(RuntimeError::LocalFileNotFound(source_path.clone())); - } - - // Verify source is executable (on Unix) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = fs::metadata(source_path)?.permissions(); - if perms.mode() & 0o111 == 0 { - return Err(RuntimeError::LocalFileNotExecutable(source_path.clone())); - } - } - - tracing::info!( - "Copying local backstage-server from {} to {}", - source_path.display(), - dest_path.display() - ); - - // Copy the file (not symlink) - fs::copy(source_path, dest_path).map_err(|e| RuntimeError::WriteFailed(e.to_string()))?; - - let bytes = fs::metadata(dest_path)?.len(); - tracing::info!( - "Copied backstage-server ({} bytes) to {}", - bytes, - dest_path.display() - ); - - Ok(()) - } - - /// Download binary from a remote https:// URL. - /// - /// Appends platform suffix to the URL (e.g., /backstage-server-bun-darwin-arm64). - fn download_remote_binary(&self, dest_path: &PathBuf) -> Result<(), RuntimeError> { - let url = format!( - "{}/backstage-server-{}", - self.release_url, - self.platform.bun_target() - ); - - tracing::info!( - "Downloading backstage-server for {} from {}", - self.platform.display_name(), - url - ); - - // Download using reqwest blocking client - let response = reqwest::blocking::get(&url) - .map_err(|e| RuntimeError::DownloadFailed(e.to_string()))?; - - if !response.status().is_success() { - return Err(RuntimeError::DownloadFailed(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))); - } - - let bytes = response - .bytes() - .map_err(|e| RuntimeError::DownloadFailed(e.to_string()))?; - - // Write binary - fs::write(dest_path, &bytes).map_err(|e| RuntimeError::WriteFailed(e.to_string()))?; - - tracing::info!( - "Downloaded backstage-server ({} bytes) to {}", - bytes.len(), - dest_path.display() - ); - - Ok(()) - } - - /// Remove the downloaded binary. - #[allow(dead_code)] // Reserved for cleanup/maintenance operations - pub fn remove_binary(&self) -> Result<(), RuntimeError> { - let binary_path = self.binary_path(); - if binary_path.exists() { - fs::remove_file(&binary_path)?; - tracing::info!("Removed backstage-server binary"); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_platform_current() { - // This should return Some on supported platforms - let platform = Platform::current(); - #[cfg(any( - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "macos", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - ))] - assert!(platform.is_some()); - } - - #[test] - fn test_platform_bun_target() { - assert_eq!(Platform::DarwinArm64.bun_target(), "bun-darwin-arm64"); - assert_eq!(Platform::DarwinX64.bun_target(), "bun-darwin-x64"); - assert_eq!(Platform::LinuxX64.bun_target(), "bun-linux-x64"); - assert_eq!(Platform::LinuxArm64.bun_target(), "bun-linux-arm64"); - } - - #[test] - fn test_platform_display_name() { - assert_eq!(Platform::DarwinArm64.display_name(), "macOS ARM64"); - assert_eq!(Platform::LinuxX64.display_name(), "Linux x64"); - } - - #[test] - fn test_runtime_binary_path() { - let runtime = BackstageRuntime::new( - PathBuf::from("/tmp/test-state"), - "https://example.com/releases".to_string(), - None, - ); - - if let Ok(runtime) = runtime { - let path = runtime.binary_path(); - assert_eq!(path, PathBuf::from("/tmp/test-state/bin/backstage-server")); - } - } - - #[test] - fn test_runtime_with_local_path() { - let runtime = BackstageRuntime::new( - PathBuf::from("/tmp/test-state"), - "https://example.com/releases".to_string(), - Some("/path/to/local/binary".to_string()), - ); - - if let Ok(runtime) = runtime { - assert_eq!( - runtime.local_binary_path, - Some(PathBuf::from("/path/to/local/binary")) - ); - } - } - - #[test] - fn test_runtime_without_local_path() { - let runtime = BackstageRuntime::new( - PathBuf::from("/tmp/test-state"), - "https://example.com/releases".to_string(), - None, - ); - - if let Ok(runtime) = runtime { - assert!(runtime.local_binary_path.is_none()); - } - } - - #[test] - fn test_runtime_unsupported_platform() { - // This test verifies the error type exists - let err = RuntimeError::UnsupportedPlatform; - assert!(err.to_string().contains("Unsupported platform")); - } - - #[test] - fn test_local_file_not_found_error() { - let err = RuntimeError::LocalFileNotFound(PathBuf::from("/nonexistent/path")); - assert!(err.to_string().contains("Local file not found")); - } - - #[test] - fn test_local_file_not_executable_error() { - let err = RuntimeError::LocalFileNotExecutable(PathBuf::from("/some/path")); - assert!(err.to_string().contains("not executable")); - } -} diff --git a/src/backstage/scaffold.rs b/src/backstage/scaffold.rs deleted file mode 100644 index 8fb7105..0000000 --- a/src/backstage/scaffold.rs +++ /dev/null @@ -1,1180 +0,0 @@ -//! Backstage scaffold generator. -//! -//! Generates a minimal, Bun-compatible Backstage deployment including: -//! - package.json with workspace configuration -//! - app-config.yaml with guest auth and file:// catalog locations -//! - Docker files for containerized deployment -//! - Minimal app and backend packages - -#![allow(dead_code)] - -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use super::branding::{BrandingAssets, BrandingDefaults}; -use super::taxonomy::Taxonomy; -use crate::config::Config; - -/// Result of scaffold generation. -#[derive(Debug, Clone)] -pub struct ScaffoldResult { - /// Files that were created. - pub created: Vec, - /// Files that were skipped (already exist). - pub skipped: Vec, - /// Errors encountered (path, error message). - pub errors: Vec<(PathBuf, String)>, - /// Output directory for the scaffold. - pub output_dir: PathBuf, -} - -impl ScaffoldResult { - /// Create a new empty result. - pub fn new(output_dir: PathBuf) -> Self { - Self { - created: Vec::new(), - skipped: Vec::new(), - errors: Vec::new(), - output_dir, - } - } - - /// Check if scaffold completed without errors. - pub fn is_success(&self) -> bool { - self.errors.is_empty() - } - - /// Generate a summary string. - pub fn summary(&self) -> String { - format!( - "{} created, {} skipped, {} errors", - self.created.len(), - self.skipped.len(), - self.errors.len() - ) - } -} - -/// Configuration for scaffold generation. -#[derive(Debug, Clone)] -pub struct ScaffoldOptions { - /// Force overwrite existing files. - pub force: bool, - /// Custom branding name (e.g., "My Company Portal"). - pub branding_name: Option, - /// Generate Docker files. - pub include_docker: bool, - /// Port for Backstage server. - pub port: u16, - /// Projects directory to scan for catalog locations. - pub projects_dir: PathBuf, -} - -impl Default for ScaffoldOptions { - fn default() -> Self { - Self { - force: false, - branding_name: None, - include_docker: true, - port: 7007, - projects_dir: PathBuf::from("."), - } - } -} - -impl ScaffoldOptions { - /// Create options from operator config. - pub fn from_config(config: &Config) -> Self { - Self { - force: false, - branding_name: None, - include_docker: true, - port: config.backstage.port, - projects_dir: config.projects_path(), - } - } - - /// Get branding configuration. - pub fn branding(&self) -> BrandingDefaults { - match &self.branding_name { - Some(name) => BrandingDefaults::with_name(name), - None => BrandingDefaults::default(), - } - } -} - -/// A single file to be scaffolded. -pub trait ScaffoldFile { - /// Relative path within the backstage output directory. - fn path(&self) -> &'static str; - - /// Generate the file content. - fn generate(&self, options: &ScaffoldOptions) -> Result; - - /// Whether this file should be created (allows conditional generation). - fn should_create(&self, _options: &ScaffoldOptions) -> bool { - true - } -} - -// ============================================================================= -// FILE GENERATORS -// ============================================================================= - -/// Generates package.json for Bun/Node workspace. -pub struct PackageJsonGenerator; - -impl ScaffoldFile for PackageJsonGenerator { - fn path(&self) -> &'static str { - "package.json" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - let name = options.branding_name.as_ref().map_or_else( - || "backstage-local".to_string(), - |n| n.to_lowercase().replace(' ', "-"), - ); - - Ok(format!( - r#"{{ - "name": "{name}", - "version": "1.0.0", - "private": true, - "engines": {{ - "node": ">=18" - }}, - "scripts": {{ - "dev": "concurrently \"yarn start\" \"yarn start-backend\"", - "start": "yarn workspace app start", - "start-backend": "yarn workspace backend start", - "build": "backstage-cli repo build --all", - "build:backend": "yarn workspace backend build" - }}, - "workspaces": {{ - "packages": [ - "packages/*", - "packages/plugins/*" - ] - }}, - "devDependencies": {{ - "@backstage/cli": "^0.27.0", - "concurrently": "^8.0.0" - }}, - "resolutions": {{ - "@types/react": "^18" - }} -}}"# - )) - } -} - -/// Generates app-config.yaml with guest auth and taxonomy kinds. -pub struct AppConfigGenerator; - -impl ScaffoldFile for AppConfigGenerator { - fn path(&self) -> &'static str { - "app-config.yaml" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - let taxonomy = Taxonomy::load(); - let branding = options.branding(); - - // Generate allowed types from taxonomy (all 24 kinds) - let kind_types: String = taxonomy - .kinds - .iter() - .map(|k| format!(" - {}", k.key)) - .collect::>() - .join("\n"); - - Ok(format!( - r"app: - title: {title} - baseUrl: http://localhost:{port} - -organization: - name: {title} - -backend: - baseUrl: http://localhost:{port} - listen: - port: {port} - database: - client: better-sqlite3 - connection: ':memory:' - -auth: - providers: - guest: - dangerouslyAllowOutsideDevelopment: true - -# Proxy configuration for Operator REST API -proxy: - '/operator': - target: 'http://localhost:7008' - changeOrigin: true - -catalog: - rules: - - allow: - - Component - - API - - Resource - - System - - Domain - - Location - - Template - - Group - - User - # Extended component types from operator taxonomy - # See: https://backstage.io/docs/features/software-catalog/extending-the-model - processors: - catalogModuleKinds: - allowedTypes: -{kind_types} - locations: - # Scan workspace for catalog-info.yaml files - - type: file - target: ../../**/catalog-info.yaml - rules: - - allow: [Component, API, Resource, System, Domain] -", - title = branding.title, - port = options.port, - kind_types = kind_types, - )) - } -} - -/// Generates app-config.production.yaml for Docker deployment. -pub struct AppConfigProductionGenerator; - -impl ScaffoldFile for AppConfigProductionGenerator { - fn path(&self) -> &'static str { - "app-config.production.yaml" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - Ok(format!( - r"app: - baseUrl: ${{BACKSTAGE_BASE_URL:-http://localhost:{port}}} - -backend: - baseUrl: ${{BACKSTAGE_BACKEND_URL:-http://localhost:{port}}} - listen: - port: {port} - database: - client: better-sqlite3 - connection: /app/data/backstage.sqlite - -# Production should use proper auth, not guest -# Uncomment and configure for production use: -# auth: -# providers: -# github: -# development: -# clientId: ${{GITHUB_CLIENT_ID}} -# clientSecret: ${{GITHUB_CLIENT_SECRET}} -", - port = options.port - )) - } -} - -/// Generates Dockerfile for containerized deployment. -pub struct DockerfileGenerator; - -impl ScaffoldFile for DockerfileGenerator { - fn path(&self) -> &'static str { - "Dockerfile" - } - - fn should_create(&self, options: &ScaffoldOptions) -> bool { - options.include_docker - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"# Stage 1: Build -FROM node:20-bookworm-slim AS builder - -WORKDIR /app - -# Install dependencies -COPY package.json yarn.lock ./ -RUN corepack enable && yarn install --immutable - -# Copy source and build -COPY . . -RUN yarn build - -# Stage 2: Runtime -FROM node:20-bookworm-slim - -WORKDIR /app - -# Copy built artifacts -COPY --from=builder /app/packages/backend/dist /app/dist -COPY --from=builder /app/node_modules /app/node_modules -COPY app-config.yaml app-config.production.yaml ./ - -# Create data directory for SQLite -RUN mkdir -p /app/data && chown node:node /app/data - -USER node - -EXPOSE 7007 - -CMD ["node", "dist/index.cjs"] -"# - .to_string()) - } -} - -/// Generates docker-compose.yaml for local Docker orchestration. -pub struct DockerComposeGenerator; - -impl ScaffoldFile for DockerComposeGenerator { - fn path(&self) -> &'static str { - "docker-compose.yaml" - } - - fn should_create(&self, options: &ScaffoldOptions) -> bool { - options.include_docker - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - Ok(format!( - r#"version: '3.8' - -services: - backstage: - build: . - ports: - - "{port}:{port}" - environment: - - BACKSTAGE_BASE_URL=http://localhost:{port} - - BACKSTAGE_BACKEND_URL=http://localhost:{port} - volumes: - - backstage-data:/app/data - # Mount workspace for catalog scanning (read-only) - - ../..:/workspace:ro - restart: unless-stopped - -volumes: - backstage-data: -"#, - port = options.port - )) - } -} - -/// Generates README.md with usage instructions. -pub struct ReadmeGenerator; - -impl ScaffoldFile for ReadmeGenerator { - fn path(&self) -> &'static str { - "README.md" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - let branding = options.branding(); - Ok(format!( - r"# {title} - -A local Backstage deployment generated by operator. - -## Quick Start - -### Local Development (Bun/Yarn) - -```bash -# Install dependencies -yarn install - -# Start dev server -yarn dev -``` - -Open http://localhost:{port} in your browser. - -### Docker - -```bash -# Build and run -docker-compose up --build - -# Or build only -docker build -t backstage-local . -docker run -p {port}:{port} backstage-local -``` - -## Configuration - -- `app-config.yaml` - Main configuration (guest auth, local catalog) -- `app-config.production.yaml` - Production overrides (Docker) - -## Catalog - -The catalog scans `../../**/catalog-info.yaml` for components. -Create a `catalog-info.yaml` in your projects to register them. - -## Branding - -Customize the portal by editing: -- `branding/logo.svg` - Portal logo -- `app-config.yaml` - Title and colors - -## Generated by - -This scaffold was generated by [operator](https://github.com/untra/operator). -", - title = branding.title, - port = options.port - )) - } -} - -/// Generates packages/app/package.json for frontend. -pub struct AppPackageGenerator; - -impl ScaffoldFile for AppPackageGenerator { - fn path(&self) -> &'static str { - "packages/app/package.json" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"{ - "name": "app", - "version": "0.0.0", - "private": true, - "bundled": true, - "scripts": { - "start": "backstage-cli package start" - }, - "dependencies": { - "@backstage/app-defaults": "^1.5.0", - "@backstage/core-app-api": "^1.14.0", - "@backstage/core-components": "^0.14.0", - "@backstage/core-plugin-api": "^1.9.0", - "@backstage/plugin-catalog": "^1.21.0", - "@backstage/plugin-catalog-graph": "^0.4.0", - "@backstage/plugin-catalog-import": "^0.12.0", - "@backstage/plugin-catalog-react": "^1.12.0", - "@backstage/theme": "^0.5.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.27.0" - } -} -"# - .to_string()) - } -} - -/// Generates packages/backend/package.json for backend. -pub struct BackendPackageGenerator; - -impl ScaffoldFile for BackendPackageGenerator { - fn path(&self) -> &'static str { - "packages/backend/package.json" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"{ - "name": "backend", - "version": "0.0.0", - "private": true, - "main": "dist/index.cjs.js", - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build" - }, - "dependencies": { - "@backstage/backend-defaults": "^0.4.0", - "@backstage/plugin-app-backend": "^0.3.0", - "@backstage/plugin-auth-backend": "^0.22.0", - "@backstage/plugin-auth-backend-module-guest-provider": "^0.1.0", - "@backstage/plugin-catalog-backend": "^1.24.0", - "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.1.0", - "@backstage/plugin-proxy-backend": "^0.5.0", - "better-sqlite3": "^9.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.27.0" - } -} -"# - .to_string()) - } -} - -/// Generates branding/logo.svg with default logo. -pub struct LogoGenerator; - -impl ScaffoldFile for LogoGenerator { - fn path(&self) -> &'static str { - "branding/logo.svg" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(BrandingAssets::default_logo_svg().to_string()) - } -} - -/// Generates packages/app/src/App.tsx - React frontend entry component. -pub struct AppTsxGenerator; - -impl ScaffoldFile for AppTsxGenerator { - fn path(&self) -> &'static str { - "packages/app/src/App.tsx" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"/** - * Operator Backstage Frontend - * - * Catalog browser with issue types management. - */ - -import React from 'react'; -import { Route } from 'react-router-dom'; -import { createApp, FlatRoutes } from '@backstage/app-defaults'; -import { catalogPlugin } from '@backstage/plugin-catalog'; -import { - issueTypesPlugin, - IssueTypesPage, - IssueTypeDetailPage, - IssueTypeFormPage, - CollectionsPage, -} from '@operator/plugin-issuetypes'; - -const app = createApp({ - plugins: [catalogPlugin, issueTypesPlugin], -}); - -const AppProvider = app.getProvider(); -const AppRouter = app.getRouter(); - -const routes = ( - - }> - } /> - } /> - } /> - } /> - - -); - -export default function App() { - return ( - - - {routes} - - - ); -} -"# - .to_string()) - } -} - -/// Generates packages/app/src/index.tsx - React DOM entry point. -pub struct AppIndexTsxGenerator; - -impl ScaffoldFile for AppIndexTsxGenerator { - fn path(&self) -> &'static str { - "packages/app/src/index.tsx" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r"/** - * Operator Backstage Frontend Entry Point - */ - -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; - -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); - -root.render( - - - -); -" - .to_string()) - } -} - -/// Generates packages/backend/src/index.ts - Bun backend entry point. -pub struct BackendIndexTsGenerator; - -impl ScaffoldFile for BackendIndexTsGenerator { - fn path(&self) -> &'static str { - "packages/backend/src/index.ts" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r"/** - * Operator Backstage Backend - * - * Minimal Bun-based backend for local catalog browsing. - * Includes proxy for Operator API integration. - */ - -import { createBackend } from '@backstage/backend-defaults'; - -const backend = createBackend(); - -// Core plugins -backend.add(import('@backstage/plugin-app-backend')); -backend.add(import('@backstage/plugin-catalog-backend')); - -// Proxy for Operator REST API -backend.add(import('@backstage/plugin-proxy-backend')); - -// Start the backend -backend.start(); -" - .to_string()) - } -} - -/// Generates tsconfig.json for TypeScript configuration. -pub struct TsConfigGenerator; - -impl ScaffoldFile for TsConfigGenerator { - fn path(&self) -> &'static str { - "tsconfig.json" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./packages", - "baseUrl": ".", - "paths": { - "backend": ["packages/backend/src"], - "app": ["packages/app/src"] - } - }, - "include": ["packages/*/src/**/*"], - "exclude": ["node_modules", "dist", "**/dist"] -} -"# - .to_string()) - } -} - -/// Generates bunfig.toml for Bun configuration. -pub struct BunfigGenerator; - -impl ScaffoldFile for BunfigGenerator { - fn path(&self) -> &'static str { - "bunfig.toml" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"# Bun configuration for Operator Backstage -# https://bun.sh/docs/runtime/bunfig - -[install] -# Use Bun's native lockfile format -lockfile = "bun.lockb" - -# NPM registry -registry = "https://registry.npmjs.org/" - -# Save dependencies to package.json -save = true -"# - .to_string()) - } -} - -// ============================================================================= -// SCAFFOLD ORCHESTRATOR -// ============================================================================= - -/// Copy a directory recursively. -fn copy_dir_recursive(src: &Path, dst: &Path, force: bool) -> Result> { - let mut copied = Vec::new(); - - if !src.exists() { - return Ok(copied); - } - - fs::create_dir_all(dst)?; - - for entry in fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if src_path.is_dir() { - let sub_copied = copy_dir_recursive(&src_path, &dst_path, force)?; - copied.extend(sub_copied); - } else { - // Skip if exists and not forcing - if dst_path.exists() && !force { - continue; - } - fs::copy(&src_path, &dst_path)?; - copied.push(dst_path); - } - } - - Ok(copied) -} - -/// The main scaffold generator. -pub struct BackstageScaffold { - output_dir: PathBuf, - options: ScaffoldOptions, - /// Path to the operator source plugins directory (for copying) - plugins_source: Option, -} - -impl BackstageScaffold { - /// Create a new scaffold generator. - pub fn new(output_dir: PathBuf, options: ScaffoldOptions) -> Self { - Self { - output_dir, - options, - plugins_source: None, - } - } - - /// Create a new scaffold generator with plugin source path. - pub fn with_plugins_source( - output_dir: PathBuf, - options: ScaffoldOptions, - plugins_source: PathBuf, - ) -> Self { - Self { - output_dir, - options, - plugins_source: Some(plugins_source), - } - } - - /// Check if scaffold already exists. - pub fn exists(output_dir: &Path) -> bool { - output_dir.join("package.json").exists() - } - - /// Get all file generators. - fn generators(&self) -> Vec> { - vec![ - Box::new(PackageJsonGenerator), - Box::new(AppConfigGenerator), - Box::new(AppConfigProductionGenerator), - Box::new(DockerfileGenerator), - Box::new(DockerComposeGenerator), - Box::new(ReadmeGenerator), - Box::new(AppPackageGenerator), - Box::new(BackendPackageGenerator), - Box::new(LogoGenerator), - // TypeScript source files - Box::new(AppTsxGenerator), - Box::new(AppIndexTsxGenerator), - Box::new(BackendIndexTsGenerator), - Box::new(TsConfigGenerator), - Box::new(BunfigGenerator), - ] - } - - /// Copy plugins from source directory to scaffold output. - fn copy_plugins(&self, result: &mut ScaffoldResult) { - if let Some(plugins_source) = &self.plugins_source { - let plugins_dest = self.output_dir.join("packages/plugins"); - - match copy_dir_recursive(plugins_source, &plugins_dest, self.options.force) { - Ok(copied) => { - result.created.extend(copied); - } - Err(e) => { - result - .errors - .push((plugins_dest, format!("Failed to copy plugins: {e}"))); - } - } - } - } - - /// Generate all scaffold files. - pub fn generate(&self) -> Result { - let mut result = ScaffoldResult::new(self.output_dir.clone()); - - // Create output directory - fs::create_dir_all(&self.output_dir).with_context(|| { - format!("Failed to create directory: {}", self.output_dir.display()) - })?; - - for generator in self.generators() { - // Check if this file should be created - if !generator.should_create(&self.options) { - continue; - } - - let rel_path = generator.path(); - let full_path = self.output_dir.join(rel_path); - - // Check if file exists and skip if not forcing - if full_path.exists() && !self.options.force { - result.skipped.push(full_path); - continue; - } - - // Create parent directories - if let Some(parent) = full_path.parent() { - if let Err(e) = fs::create_dir_all(parent) { - result.errors.push(( - full_path.clone(), - format!("Failed to create directory: {e}"), - )); - continue; - } - } - - // Generate and write content - match generator.generate(&self.options) { - Ok(content) => { - if let Err(e) = fs::write(&full_path, content) { - result - .errors - .push((full_path, format!("Failed to write: {e}"))); - } else { - result.created.push(full_path); - } - } - Err(e) => { - result - .errors - .push((full_path, format!("Failed to generate: {e}"))); - } - } - } - - // Copy plugins directory if source is provided - self.copy_plugins(&mut result); - - Ok(result) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_scaffold_result_summary() { - let mut result = ScaffoldResult::new(PathBuf::from("/test")); - result.created.push(PathBuf::from("package.json")); - result.skipped.push(PathBuf::from("existing.yaml")); - assert!(result.summary().contains("1 created")); - assert!(result.summary().contains("1 skipped")); - assert!(result.is_success()); - } - - #[test] - fn test_scaffold_result_with_errors() { - let mut result = ScaffoldResult::new(PathBuf::from("/test")); - result - .errors - .push((PathBuf::from("bad.txt"), "error".to_string())); - assert!(!result.is_success()); - } - - #[test] - fn test_scaffold_options_default() { - let options = ScaffoldOptions::default(); - assert!(!options.force); - assert!(options.include_docker); - assert_eq!(options.port, 7007); - } - - #[test] - fn test_scaffold_options_branding() { - let options = ScaffoldOptions { - branding_name: Some("My Portal".to_string()), - ..Default::default() - }; - let branding = options.branding(); - assert_eq!(branding.title, "My Portal"); - } - - #[test] - fn test_package_json_generator() { - let gen = PackageJsonGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("\"name\": \"backstage-local\"")); - assert!(content.contains("backstage-cli")); - assert!(content.contains("workspaces")); - } - - #[test] - fn test_package_json_with_custom_name() { - let gen = PackageJsonGenerator; - let options = ScaffoldOptions { - branding_name: Some("My Company Portal".to_string()), - ..Default::default() - }; - let content = gen.generate(&options).unwrap(); - assert!(content.contains("\"name\": \"my-company-portal\"")); - } - - #[test] - fn test_app_config_includes_all_kinds() { - let gen = AppConfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - - // Verify all kinds are present (flexible - works with any number) - let taxonomy = Taxonomy::load(); - assert!(!taxonomy.kinds.is_empty(), "Should have kinds"); - for kind in &taxonomy.kinds { - assert!(content.contains(&kind.key), "Missing kind: {}", kind.key); - } - } - - #[test] - fn test_app_config_has_guest_auth() { - let gen = AppConfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("guest:")); - assert!(content.contains("dangerouslyAllowOutsideDevelopment: true")); - } - - #[test] - fn test_dockerfile_conditional_creation() { - let gen = DockerfileGenerator; - - let with_docker = ScaffoldOptions { - include_docker: true, - ..Default::default() - }; - assert!(gen.should_create(&with_docker)); - - let without_docker = ScaffoldOptions { - include_docker: false, - ..Default::default() - }; - assert!(!gen.should_create(&without_docker)); - } - - #[test] - fn test_dockerfile_content() { - let gen = DockerfileGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("FROM node:20-bookworm-slim")); - assert!(content.contains("EXPOSE 7007")); - } - - #[test] - fn test_docker_compose_uses_port() { - let gen = DockerComposeGenerator; - let options = ScaffoldOptions { - port: 8080, - ..Default::default() - }; - let content = gen.generate(&options).unwrap(); - assert!(content.contains("8080:8080")); - } - - #[test] - fn test_readme_generator() { - let gen = ReadmeGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("Developer Portal")); - assert!(content.contains("yarn dev")); - assert!(content.contains("docker-compose")); - } - - #[test] - fn test_logo_generator() { - let gen = LogoGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("")); - } - - #[test] - fn test_app_tsx_generator() { - let gen = AppTsxGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("import React from 'react'")); - assert!(content.contains("@backstage/app-defaults")); - assert!(content.contains("catalogPlugin")); - assert!(content.contains("export default function App()")); - } - - #[test] - fn test_app_index_tsx_generator() { - let gen = AppIndexTsxGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("ReactDOM.createRoot")); - assert!(content.contains("import App from './App'")); - assert!(content.contains("")); - } - - #[test] - fn test_backend_index_ts_generator() { - let gen = BackendIndexTsGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("@backstage/backend-defaults")); - assert!(content.contains("createBackend")); - assert!(content.contains("plugin-app-backend")); - assert!(content.contains("plugin-catalog-backend")); - assert!(content.contains("backend.start()")); - } - - #[test] - fn test_tsconfig_generator() { - let gen = TsConfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("\"target\": \"ES2022\"")); - assert!(content.contains("\"jsx\": \"react-jsx\"")); - assert!(content.contains("\"moduleResolution\": \"bundler\"")); - } - - #[test] - fn test_bunfig_generator() { - let gen = BunfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("[install]")); - assert!(content.contains("lockfile = \"bun.lockb\"")); - assert!(content.contains("registry.npmjs.org")); - } - - #[test] - fn test_scaffold_exists() { - let temp_dir = TempDir::new().unwrap(); - assert!(!BackstageScaffold::exists(temp_dir.path())); - - // Create package.json - fs::write(temp_dir.path().join("package.json"), "{}").unwrap(); - assert!(BackstageScaffold::exists(temp_dir.path())); - } - - #[test] - fn test_scaffold_generate() { - let temp_dir = TempDir::new().unwrap(); - let scaffold = - BackstageScaffold::new(temp_dir.path().to_path_buf(), ScaffoldOptions::default()); - - let result = scaffold.generate().unwrap(); - assert!(result.is_success()); - assert!(!result.created.is_empty()); - assert!(result.skipped.is_empty()); - - // Verify key files exist - assert!(temp_dir.path().join("package.json").exists()); - assert!(temp_dir.path().join("app-config.yaml").exists()); - assert!(temp_dir.path().join("Dockerfile").exists()); - assert!(temp_dir.path().join("docker-compose.yaml").exists()); - assert!(temp_dir.path().join("README.md").exists()); - assert!(temp_dir.path().join("packages/app/package.json").exists()); - assert!(temp_dir - .path() - .join("packages/backend/package.json") - .exists()); - assert!(temp_dir.path().join("branding/logo.svg").exists()); - - // Verify TypeScript source files exist - assert!(temp_dir.path().join("packages/app/src/App.tsx").exists()); - assert!(temp_dir.path().join("packages/app/src/index.tsx").exists()); - assert!(temp_dir - .path() - .join("packages/backend/src/index.ts") - .exists()); - assert!(temp_dir.path().join("tsconfig.json").exists()); - assert!(temp_dir.path().join("bunfig.toml").exists()); - } - - #[test] - fn test_scaffold_idempotency() { - let temp_dir = TempDir::new().unwrap(); - let scaffold = - BackstageScaffold::new(temp_dir.path().to_path_buf(), ScaffoldOptions::default()); - - // First run - creates files - let result1 = scaffold.generate().unwrap(); - assert!(!result1.created.is_empty()); - assert!(result1.skipped.is_empty()); - - // Second run - skips existing - let result2 = scaffold.generate().unwrap(); - assert!(result2.created.is_empty()); - assert!(!result2.skipped.is_empty()); - } - - #[test] - fn test_scaffold_force_overwrites() { - let temp_dir = TempDir::new().unwrap(); - - // First run without force - let scaffold1 = - BackstageScaffold::new(temp_dir.path().to_path_buf(), ScaffoldOptions::default()); - scaffold1.generate().unwrap(); - - // Second run with force - let scaffold2 = BackstageScaffold::new( - temp_dir.path().to_path_buf(), - ScaffoldOptions { - force: true, - ..Default::default() - }, - ); - let result = scaffold2.generate().unwrap(); - assert!(!result.created.is_empty()); - assert!(result.skipped.is_empty()); - } - - #[test] - fn test_scaffold_without_docker() { - let temp_dir = TempDir::new().unwrap(); - let scaffold = BackstageScaffold::new( - temp_dir.path().to_path_buf(), - ScaffoldOptions { - include_docker: false, - ..Default::default() - }, - ); - - let result = scaffold.generate().unwrap(); - assert!(result.is_success()); - - // Docker files should not exist - assert!(!temp_dir.path().join("Dockerfile").exists()); - assert!(!temp_dir.path().join("docker-compose.yaml").exists()); - - // Other files should exist - assert!(temp_dir.path().join("package.json").exists()); - assert!(temp_dir.path().join("app-config.yaml").exists()); - } -} diff --git a/src/backstage/server.rs b/src/backstage/server.rs deleted file mode 100644 index 217ee48..0000000 --- a/src/backstage/server.rs +++ /dev/null @@ -1,1102 +0,0 @@ -//! Backstage server lifecycle management. -//! -//! Provides a trait-based abstraction over server operations to enable: -//! - Unit testing without real binaries -//! - Mocking server behavior -//! - Compiled binary mode (no Bun required) -//! - Development mode with Bun - -// M6 TUI integration complete - some methods still reserved for future Backstage API -#![allow(dead_code)] - -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use thiserror::Error; - -use super::runtime::{BackstageRuntime, RuntimeError}; -use crate::config::BrandingConfig; - -/// Errors specific to Backstage operations -#[derive(Error, Debug)] -pub enum BackstageError { - #[error("bun is not installed or not in PATH")] - BunNotInstalled, - - #[error("bun version {0} is below minimum required version {1}")] - #[allow(dead_code)] // Reserved for future version enforcement - BunVersionTooOld(String, String), - - #[error("backstage scaffold not found at {0}")] - ScaffoldNotFound(PathBuf), - - #[error("backstage server is not running")] - ServerNotRunning, - - #[error("backstage server is already running on port {0}")] - ServerAlreadyRunning(u16), - - #[error("failed to start backstage server: {0}")] - StartFailed(String), - - #[error("failed to stop backstage server: {0}")] - StopFailed(String), - - #[error("bun command failed: {0}")] - CommandFailed(String), - - #[error("server not ready after {timeout_ms}ms on port {port}")] - ServerNotReady { port: u16, timeout_ms: u64 }, - - #[error("runtime error: {0}")] - Runtime(#[from] RuntimeError), -} - -/// Version information for Bun -#[derive(Debug, Clone, PartialEq)] -pub struct BunVersion { - pub major: u32, - pub minor: u32, - pub patch: u32, - pub raw: String, -} - -impl BunVersion { - /// Parse a version string like "1.1.42" or "v1.1.42" - pub fn parse(version_str: &str) -> Option { - let clean = version_str.trim().trim_start_matches('v'); - let parts: Vec<&str> = clean.split('.').collect(); - - if parts.is_empty() { - return None; - } - - let major: u32 = parts[0].parse().ok()?; - let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); - let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - - Some(Self { - major, - minor, - patch, - raw: version_str.to_string(), - }) - } - - /// Check if this version meets the minimum requirement - #[allow(dead_code)] // Used in tests, reserved for future version enforcement - pub fn meets_minimum(&self, min: &BunVersion) -> bool { - (self.major, self.minor, self.patch) >= (min.major, min.minor, min.patch) - } -} - -impl std::fmt::Display for BunVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch) - } -} - -/// Generate branding configuration file for backstage-server. -/// -/// Creates a `theme.json` file in the branding directory that backstage-server -/// reads on startup to configure the portal's theme and branding. -/// -/// # Arguments -/// * `branding_path` - Directory to write the theme.json file -/// * `config` - Branding configuration from Operator config -/// -/// # Returns -/// * `Ok(())` on success -/// * `Err` if directory creation or file writing fails -pub fn generate_branding_config( - branding_path: &Path, - config: &BrandingConfig, -) -> std::io::Result<()> { - // Create branding directory if it doesn't exist - std::fs::create_dir_all(branding_path)?; - - // Build theme configuration JSON - let theme = serde_json::json!({ - "appTitle": config.app_title, - "orgName": config.org_name, - "logoPath": config.logo_path, - "colors": { - "primary": config.colors.primary, - "secondary": config.colors.secondary, - "accent": config.colors.accent, - "warning": config.colors.warning, - "muted": config.colors.muted, - } - }); - - // Write theme.json - let theme_path = branding_path.join("theme.json"); - let theme_json = serde_json::to_string_pretty(&theme) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - std::fs::write(&theme_path, theme_json)?; - - tracing::info!( - path = %theme_path.display(), - app_title = %config.app_title, - "Generated backstage branding config" - ); - - Ok(()) -} - -/// Copy default logo to branding directory if not already present. -/// -/// Copies `img/operator_logo.svg` to the branding directory as `logo.svg` -/// on first setup if no logo already exists. -/// -/// # Arguments -/// * `branding_path` - Directory containing branding assets -/// * `source_logo_path` - Path to the source logo file -pub fn copy_default_logo(branding_path: &Path, source_logo_path: &Path) -> std::io::Result<()> { - let dest_logo = branding_path.join("logo.svg"); - - // Only copy if destination doesn't exist - if !dest_logo.exists() && source_logo_path.exists() { - std::fs::create_dir_all(branding_path)?; - std::fs::copy(source_logo_path, &dest_logo)?; - tracing::info!( - source = %source_logo_path.display(), - dest = %dest_logo.display(), - "Copied default logo to branding directory" - ); - } - - Ok(()) -} - -/// Server status information -#[derive(Debug, Clone, PartialEq)] -pub enum ServerStatus { - Stopped, - Starting, - Stopping, - Running { - port: u16, - pid: u32, - }, - #[allow(dead_code)] // Used in StatusBar rendering - Error(String), -} - -impl ServerStatus { - /// Returns true if the server is running - pub fn is_running(&self) -> bool { - matches!(self, ServerStatus::Running { .. }) - } -} - -/// Trait abstracting Bun operations for testability -pub trait BunClient: Send + Sync { - /// Check if Bun is available and return version info - fn check_available(&self) -> Result; - - /// Check if dependencies are installed - fn check_dependencies(&self, scaffold_path: &Path) -> Result; - - /// Install dependencies (bun install) - fn install_dependencies(&self, scaffold_path: &Path) -> Result<(), BackstageError>; - - /// Start the Backstage server, returns process handle - fn start_server(&self, scaffold_path: &Path, port: u16) -> Result; - - /// Check if a process is still running - fn is_process_running(&self, pid: u32) -> bool; -} - -/// Real implementation using system Bun -pub struct SystemBunClient; - -impl SystemBunClient { - pub fn new() -> Self { - Self - } - - fn run_bun( - &self, - args: &[&str], - cwd: Option<&Path>, - ) -> Result { - let mut cmd = Command::new("bun"); - cmd.args(args); - - if let Some(dir) = cwd { - cmd.current_dir(dir); - } - - cmd.output().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - BackstageError::BunNotInstalled - } else { - BackstageError::CommandFailed(e.to_string()) - } - }) - } -} - -impl Default for SystemBunClient { - fn default() -> Self { - Self::new() - } -} - -impl BunClient for SystemBunClient { - fn check_available(&self) -> Result { - let output = self.run_bun(&["--version"], None)?; - - if !output.status.success() { - return Err(BackstageError::BunNotInstalled); - } - - let version_str = String::from_utf8_lossy(&output.stdout); - BunVersion::parse(version_str.trim()).ok_or_else(|| { - BackstageError::CommandFailed(format!("Could not parse version: {version_str}")) - }) - } - - fn check_dependencies(&self, scaffold_path: &Path) -> Result { - let node_modules = scaffold_path.join("node_modules"); - let lockfile = scaffold_path.join("bun.lockb"); - - Ok(node_modules.exists() && lockfile.exists()) - } - - fn install_dependencies(&self, scaffold_path: &Path) -> Result<(), BackstageError> { - let output = self.run_bun(&["install"], Some(scaffold_path))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BackstageError::CommandFailed(format!( - "bun install failed: {stderr}" - ))); - } - - Ok(()) - } - - fn start_server(&self, scaffold_path: &Path, port: u16) -> Result { - // Start backend with bun - // Command: bun run packages/backend/src/index.ts - let child = Command::new("bun") - .args(["run", "packages/backend/src/index.ts"]) - .current_dir(scaffold_path) - .env("PORT", port.to_string()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - BackstageError::BunNotInstalled - } else { - BackstageError::StartFailed(e.to_string()) - } - })?; - - Ok(child) - } - - fn is_process_running(&self, pid: u32) -> bool { - use sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - sys.process(sysinfo::Pid::from_u32(pid)).is_some() - } -} - -/// Client that uses a pre-compiled backstage-server binary. -/// -/// Downloads the platform-specific binary on first use and executes it directly. -/// This eliminates the need for users to have Bun/Node installed. -pub struct RuntimeBinaryClient { - runtime: BackstageRuntime, -} - -impl RuntimeBinaryClient { - /// Create a new runtime binary client. - /// - /// # Arguments - /// * `state_path` - Directory to store the binary (e.g., .tickets/operator) - /// * `release_url` - Base URL for downloading binaries - /// * `local_binary_path` - Optional local path to binary (takes precedence over URL) - pub fn new( - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - ) -> Result { - let runtime = BackstageRuntime::new(state_path, release_url, local_binary_path)?; - Ok(Self { runtime }) - } -} - -impl BunClient for RuntimeBinaryClient { - fn check_available(&self) -> Result { - // For compiled binary, we return a synthetic version indicating binary mode - // The binary is self-contained and doesn't need Bun - Ok(BunVersion { - major: 0, - minor: 0, - patch: 0, - raw: "compiled-binary".to_string(), - }) - } - - fn check_dependencies(&self, _scaffold_path: &Path) -> Result { - // For compiled binary, dependencies are bundled in the binary - // We just need to ensure the binary exists - Ok(self.runtime.binary_exists()) - } - - fn install_dependencies(&self, _scaffold_path: &Path) -> Result<(), BackstageError> { - // "Installing dependencies" means downloading the binary - self.runtime.ensure_binary()?; - Ok(()) - } - - fn start_server(&self, _scaffold_path: &Path, port: u16) -> Result { - // Ensure binary is available - let binary_path = self.runtime.ensure_binary()?; - - tracing::info!( - binary = %binary_path.display(), - port = port, - platform = %self.runtime.platform().display_name(), - "Starting backstage-server binary" - ); - - // Start the compiled binary - let child = Command::new(&binary_path) - .env("PORT", port.to_string()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| BackstageError::StartFailed(e.to_string()))?; - - Ok(child) - } - - fn is_process_running(&self, pid: u32) -> bool { - use sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - sys.process(sysinfo::Pid::from_u32(pid)).is_some() - } -} - -/// Backstage server handle for lifecycle management -pub struct BackstageServer { - client: Arc, - scaffold_path: PathBuf, - port: u16, - process: Mutex>, - status: Mutex, -} - -impl BackstageServer { - /// Create a new server handle - pub fn new(client: Arc, scaffold_path: PathBuf, port: u16) -> Self { - Self { - client, - scaffold_path, - port, - process: Mutex::new(None), - status: Mutex::new(ServerStatus::Stopped), - } - } - - /// Create with system Bun client (development mode) - pub fn with_system_client(scaffold_path: PathBuf, port: u16) -> Self { - Self::new(Arc::new(SystemBunClient::new()), scaffold_path, port) - } - - /// Create with compiled binary client (production mode). - /// - /// Downloads and uses a pre-compiled backstage-server binary that doesn't - /// require Bun/Node to be installed. The binary is downloaded on first use. - /// - /// # Arguments - /// * `state_path` - Directory to store the binary (e.g., .tickets/operator) - /// * `release_url` - Base URL for downloading binaries - /// * `local_binary_path` - Optional local path to binary (takes precedence over URL) - /// * `port` - Port to run the server on - pub fn with_compiled_binary( - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - port: u16, - ) -> Result { - let client = RuntimeBinaryClient::new(state_path.clone(), release_url, local_binary_path)?; - Ok(Self::new(Arc::new(client), state_path, port)) - } - - /// Get current server status - pub fn status(&self) -> ServerStatus { - self.status.lock().unwrap().clone() - } - - /// Check if server is running - pub fn is_running(&self) -> bool { - self.status().is_running() - } - - /// Start the Backstage server - pub fn start(&self) -> Result<(), BackstageError> { - // Check if already running - if self.is_running() { - return Err(BackstageError::ServerAlreadyRunning(self.port)); - } - - // Check scaffold exists - if !self.scaffold_path.exists() { - return Err(BackstageError::ScaffoldNotFound(self.scaffold_path.clone())); - } - - // Check Bun is available - let version = self.client.check_available()?; - tracing::info!(version = %version.raw, "Bun available"); - - // Check/install dependencies - if !self.client.check_dependencies(&self.scaffold_path)? { - tracing::info!("Installing Backstage dependencies..."); - *self.status.lock().unwrap() = ServerStatus::Starting; - self.client.install_dependencies(&self.scaffold_path)?; - } - - // Start server - *self.status.lock().unwrap() = ServerStatus::Starting; - let mut child = self.client.start_server(&self.scaffold_path, self.port)?; - let pid = child.id(); - - // Spawn threads to forward stdout/stderr to tracing - if let Some(stdout) = child.stdout.take() { - std::thread::spawn(move || { - use std::io::{BufRead, BufReader}; - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - tracing::info!(target: "backstage", "{}", line); - } - }); - } - - if let Some(stderr) = child.stderr.take() { - std::thread::spawn(move || { - use std::io::{BufRead, BufReader}; - let reader = BufReader::new(stderr); - for line in reader.lines().map_while(Result::ok) { - tracing::warn!(target: "backstage", "{}", line); - } - }); - } - - *self.process.lock().unwrap() = Some(child); - *self.status.lock().unwrap() = ServerStatus::Running { - port: self.port, - pid, - }; - - tracing::info!(port = self.port, pid = pid, "Backstage server started"); - - Ok(()) - } - - /// Stop the Backstage server - pub fn stop(&self) -> Result<(), BackstageError> { - let mut process_guard = self.process.lock().unwrap(); - - if let Some(ref mut child) = *process_guard { - // Set stopping status first for visual feedback - *self.status.lock().unwrap() = ServerStatus::Stopping; - - child - .kill() - .map_err(|e| BackstageError::StopFailed(e.to_string()))?; - child - .wait() - .map_err(|e| BackstageError::StopFailed(e.to_string()))?; - tracing::info!("Backstage server stopped"); - } - - *process_guard = None; - *self.status.lock().unwrap() = ServerStatus::Stopped; - - Ok(()) - } - - /// Toggle server state (start if stopped, stop if running) - pub fn toggle(&self) -> Result<(), BackstageError> { - if self.is_running() { - self.stop() - } else { - self.start() - } - } - - /// Open Backstage in default browser - /// - /// Checks `$BROWSER` environment variable first, then falls back to - /// platform-specific defaults (`open` on macOS, `xdg-open` on Linux). - pub fn open_browser(&self) -> Result<(), BackstageError> { - if !self.is_running() { - return Err(BackstageError::ServerNotRunning); - } - - let url = format!("http://localhost:{}", self.port); - - // Check $BROWSER environment variable first - if let Ok(browser) = std::env::var("BROWSER") { - let _ = Command::new(&browser).arg(&url).spawn(); - return Ok(()); - } - - // Fall back to platform-specific defaults - #[cfg(target_os = "macos")] - { - let _ = Command::new("open").arg(&url).spawn(); - } - - #[cfg(target_os = "linux")] - { - let _ = Command::new("xdg-open").arg(&url).spawn(); - } - - #[cfg(target_os = "windows")] - { - let _ = Command::new("cmd").args(["/C", "start", &url]).spawn(); - } - - Ok(()) - } - - /// Wait for server to be ready (health endpoint responds). - /// - /// Polls the `/health` endpoint every 500ms until it responds with success - /// or the timeout is reached. - /// - /// # Arguments - /// * `timeout_ms` - Maximum time to wait in milliseconds - /// - /// # Returns - /// * `Ok(())` if server is ready - /// * `Err(BackstageError::ServerNotReady)` if timeout reached - pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<(), BackstageError> { - let url = format!("http://localhost:{}/health", self.port); - let check_interval = Duration::from_millis(500); - let max_attempts = (timeout_ms / 500) as usize; - - tracing::debug!( - url = %url, - max_attempts = max_attempts, - "Waiting for server to be ready" - ); - - for attempt in 1..=max_attempts { - match reqwest::blocking::Client::new() - .get(&url) - .timeout(Duration::from_secs(2)) - .send() - { - Ok(response) if response.status().is_success() => { - tracing::info!(attempts = attempt, "Server ready"); - return Ok(()); - } - Ok(response) => { - tracing::debug!( - attempt = attempt, - status = %response.status(), - "Health check returned non-success status" - ); - } - Err(e) => { - tracing::debug!( - attempt = attempt, - error = %e, - "Health check failed, retrying..." - ); - } - } - std::thread::sleep(check_interval); - } - - Err(BackstageError::ServerNotReady { - port: self.port, - timeout_ms, - }) - } - - /// Refresh status by checking if process is still running - pub fn refresh_status(&self) { - let mut status_guard = self.status.lock().unwrap(); - - if let ServerStatus::Running { pid, .. } = *status_guard { - if !self.client.is_process_running(pid) { - *status_guard = ServerStatus::Stopped; - *self.process.lock().unwrap() = None; - tracing::warn!(pid = pid, "Backstage server process died unexpectedly"); - } - } - } - - /// Get the server URL - #[allow(dead_code)] // Used in tests, reserved for future Backstage API - pub fn url(&self) -> String { - format!("http://localhost:{}", self.port) - } - - /// Get the port - #[allow(dead_code)] // Used in tests, reserved for future API - pub fn port(&self) -> u16 { - self.port - } - - /// Get the scaffold path - #[allow(dead_code)] // Used in tests - pub fn scaffold_path(&self) -> &Path { - &self.scaffold_path - } -} - -// Ensure server is stopped when handle is dropped -impl Drop for BackstageServer { - fn drop(&mut self) { - if self.is_running() { - let _ = self.stop(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Mock implementation for testing - #[derive(Default)] - pub struct MockBunClient { - pub installed: Mutex, - pub version: Mutex>, - pub deps_installed: Mutex, - pub running_pids: Mutex>, - pub next_pid: Mutex, - pub fail_start: Mutex, - } - - impl MockBunClient { - pub fn new() -> Self { - Self { - installed: Mutex::new(true), - version: Mutex::new(Some(BunVersion { - major: 1, - minor: 1, - patch: 42, - raw: "1.1.42".to_string(), - })), - deps_installed: Mutex::new(true), - running_pids: Mutex::new(Vec::new()), - next_pid: Mutex::new(1000), - fail_start: Mutex::new(false), - } - } - - pub fn not_installed() -> Self { - let mock = Self::new(); - *mock.installed.lock().unwrap() = false; - mock - } - - pub fn with_deps_not_installed() -> Self { - let mock = Self::new(); - *mock.deps_installed.lock().unwrap() = false; - mock - } - - pub fn mark_pid_stopped(&self, pid: u32) { - self.running_pids.lock().unwrap().retain(|&p| p != pid); - } - } - - impl BunClient for MockBunClient { - fn check_available(&self) -> Result { - if !*self.installed.lock().unwrap() { - return Err(BackstageError::BunNotInstalled); - } - - self.version - .lock() - .unwrap() - .clone() - .ok_or(BackstageError::BunNotInstalled) - } - - fn check_dependencies(&self, _scaffold_path: &Path) -> Result { - Ok(*self.deps_installed.lock().unwrap()) - } - - fn install_dependencies(&self, _scaffold_path: &Path) -> Result<(), BackstageError> { - *self.deps_installed.lock().unwrap() = true; - Ok(()) - } - - fn start_server(&self, _scaffold_path: &Path, _port: u16) -> Result { - if *self.fail_start.lock().unwrap() { - return Err(BackstageError::StartFailed( - "Mock configured to fail".to_string(), - )); - } - - // Track the PID - let mut next_pid = self.next_pid.lock().unwrap(); - let pid = *next_pid; - *next_pid += 1; - self.running_pids.lock().unwrap().push(pid); - - // Return a real dummy process (sleep) for testing - // This allows us to test the Child handling - Command::new("sleep") - .arg("3600") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| BackstageError::StartFailed(e.to_string())) - } - - fn is_process_running(&self, pid: u32) -> bool { - self.running_pids.lock().unwrap().contains(&pid) - } - } - - use tempfile::TempDir; - - // ==================== BunVersion Tests ==================== - - #[test] - fn test_bun_version_parse_standard() { - let v = BunVersion::parse("1.1.42").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 1); - assert_eq!(v.patch, 42); - } - - #[test] - fn test_bun_version_parse_with_v_prefix() { - let v = BunVersion::parse("v1.0.0").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - } - - #[test] - fn test_bun_version_parse_two_part() { - let v = BunVersion::parse("2.0").unwrap(); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - } - - #[test] - fn test_bun_version_parse_single_part() { - let v = BunVersion::parse("3").unwrap(); - assert_eq!(v.major, 3); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - } - - #[test] - fn test_bun_version_parse_with_whitespace() { - let v = BunVersion::parse(" 1.2.3 \n").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - } - - #[test] - fn test_bun_version_parse_invalid() { - assert!(BunVersion::parse("").is_none()); - assert!(BunVersion::parse("not-a-version").is_none()); - assert!(BunVersion::parse("a.b.c").is_none()); - } - - #[test] - fn test_bun_version_meets_minimum_equal() { - let v = BunVersion::parse("1.0.0").unwrap(); - let min = BunVersion::parse("1.0.0").unwrap(); - assert!(v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_meets_minimum_higher() { - let v = BunVersion::parse("1.1.42").unwrap(); - let min = BunVersion::parse("1.0.0").unwrap(); - assert!(v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_meets_minimum_lower() { - let v = BunVersion::parse("1.0.0").unwrap(); - let min = BunVersion::parse("2.0.0").unwrap(); - assert!(!v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_meets_minimum_minor() { - let v = BunVersion::parse("1.5.0").unwrap(); - let min = BunVersion::parse("1.4.0").unwrap(); - assert!(v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_display() { - let v = BunVersion::parse("1.2.3").unwrap(); - assert_eq!(format!("{v}"), "1.2.3"); - } - - // ==================== ServerStatus Tests ==================== - - #[test] - fn test_server_status_is_running() { - assert!(!ServerStatus::Stopped.is_running()); - assert!(!ServerStatus::Starting.is_running()); - assert!(!ServerStatus::Stopping.is_running()); - assert!(ServerStatus::Running { - port: 7007, - pid: 123 - } - .is_running()); - assert!(!ServerStatus::Error("test".to_string()).is_running()); - } - - // ==================== MockBunClient Tests ==================== - - #[test] - fn test_mock_client_available() { - let client = MockBunClient::new(); - let version = client.check_available().unwrap(); - assert_eq!(version.major, 1); - assert_eq!(version.minor, 1); - assert_eq!(version.patch, 42); - } - - #[test] - fn test_mock_client_not_installed() { - let client = MockBunClient::not_installed(); - assert!(matches!( - client.check_available(), - Err(BackstageError::BunNotInstalled) - )); - } - - #[test] - fn test_mock_client_deps_installed() { - let client = MockBunClient::new(); - let path = PathBuf::from("/test"); - assert!(client.check_dependencies(&path).unwrap()); - } - - #[test] - fn test_mock_client_deps_not_installed() { - let client = MockBunClient::with_deps_not_installed(); - let path = PathBuf::from("/test"); - assert!(!client.check_dependencies(&path).unwrap()); - } - - #[test] - fn test_mock_client_install_deps() { - let client = MockBunClient::with_deps_not_installed(); - let path = PathBuf::from("/test"); - assert!(!client.check_dependencies(&path).unwrap()); - - client.install_dependencies(&path).unwrap(); - assert!(client.check_dependencies(&path).unwrap()); - } - - #[test] - fn test_mock_client_process_running() { - let client = MockBunClient::new(); - client.running_pids.lock().unwrap().push(123); - assert!(client.is_process_running(123)); - assert!(!client.is_process_running(456)); - } - - #[test] - fn test_mock_client_mark_pid_stopped() { - let client = MockBunClient::new(); - client.running_pids.lock().unwrap().push(123); - assert!(client.is_process_running(123)); - - client.mark_pid_stopped(123); - assert!(!client.is_process_running(123)); - } - - // ==================== BackstageServer Tests ==================== - - #[test] - fn test_server_status_initial() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 7007); - - assert_eq!(server.status(), ServerStatus::Stopped); - assert!(!server.is_running()); - } - - #[test] - fn test_server_url() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 7007); - - assert_eq!(server.url(), "http://localhost:7007"); - } - - #[test] - fn test_server_port() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 8080); - - assert_eq!(server.port(), 8080); - } - - #[test] - fn test_server_scaffold_path() { - let client = Arc::new(MockBunClient::new()); - let path = PathBuf::from("/my/scaffold"); - let server = BackstageServer::new(client, path.clone(), 7007); - - assert_eq!(server.scaffold_path(), path); - } - - #[test] - fn test_server_scaffold_not_found() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/nonexistent/path"), 7007); - - let result = server.start(); - assert!(matches!(result, Err(BackstageError::ScaffoldNotFound(_)))); - } - - #[test] - fn test_server_bun_not_installed() { - let client = Arc::new(MockBunClient::not_installed()); - let temp_dir = TempDir::new().unwrap(); - - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - let result = server.start(); - assert!(matches!(result, Err(BackstageError::BunNotInstalled))); - } - - #[test] - fn test_server_start_and_stop() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - // Start - server.start().unwrap(); - assert!(server.is_running()); - - // Stop - server.stop().unwrap(); - assert!(!server.is_running()); - } - - #[test] - fn test_server_already_running() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - server.start().unwrap(); - - let result = server.start(); - assert!(matches!( - result, - Err(BackstageError::ServerAlreadyRunning(7007)) - )); - - server.stop().unwrap(); - } - - #[test] - fn test_server_toggle_start() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - assert!(!server.is_running()); - server.toggle().unwrap(); - assert!(server.is_running()); - - server.stop().unwrap(); - } - - #[test] - fn test_server_toggle_stop() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - server.start().unwrap(); - assert!(server.is_running()); - - server.toggle().unwrap(); - assert!(!server.is_running()); - } - - #[test] - fn test_open_browser_when_not_running() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 7007); - - let result = server.open_browser(); - assert!(matches!(result, Err(BackstageError::ServerNotRunning))); - } - - #[test] - fn test_server_with_system_client() { - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::with_system_client(temp_dir.path().to_path_buf(), 7007); - - assert_eq!(server.port(), 7007); - assert!(!server.is_running()); - } - - #[test] - fn test_server_installs_deps_if_missing() { - let client = Arc::new(MockBunClient::with_deps_not_installed()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client.clone(), temp_dir.path().to_path_buf(), 7007); - - // Verify deps not installed initially - assert!(!client.check_dependencies(temp_dir.path()).unwrap()); - - // Start should install them - server.start().unwrap(); - - // Deps should now be installed - assert!(client.check_dependencies(temp_dir.path()).unwrap()); - - server.stop().unwrap(); - } - - // ==================== Integration Tests ==================== - - #[test] - #[ignore = "requires bun installation"] - fn test_real_bun_version_check() { - let client = SystemBunClient::new(); - let version = client.check_available().expect("Bun should be available"); - - assert!(version.major >= 1, "Bun version should be 1.x or higher"); - } - - #[test] - #[ignore = "requires bun installation"] - fn test_real_check_dependencies_empty_dir() { - let client = SystemBunClient::new(); - let temp_dir = TempDir::new().unwrap(); - - // Empty directory should not have deps - assert!(!client.check_dependencies(temp_dir.path()).unwrap()); - } -} diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index 8a9d1a1..b744095 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -29,18 +29,18 @@ use operator::api::providers::kanban::{ JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, }; use operator::config::{ - AgentsConfig, ApiConfig, BackstageConfig, BrandingConfig, CollectionPreset, Config, Delegator, - DelegatorLaunchConfig, DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, - LoggingConfig, NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, - SkillDirectoriesOverride, TemplatesConfig, ThemeColors, TmuxConfig, ToolCapabilities, UiConfig, - YoloConfig, + AgentsConfig, ApiConfig, CollectionPreset, Config, Delegator, DelegatorLaunchConfig, + DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, LoggingConfig, + NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, + SkillDirectoriesOverride, TemplatesConfig, TmuxConfig, ToolCapabilities, UiConfig, YoloConfig, }; use operator::queue::LlmTask; use operator::rest::dto::{ CollectionResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, - FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, SkillEntry, SkillsResponse, - StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, SectionDto, SectionRowDto, + SkillEntry, SkillsResponse, StatusResponse, StepResponse, UpdateIssueTypeRequest, + UpdateStepRequest, WorkflowExportResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -105,9 +105,6 @@ fn generate_typescript() -> String { DockerConfig::decl(), YoloConfig::decl(), TmuxConfig::decl(), - BackstageConfig::decl(), - BrandingConfig::decl(), - ThemeColors::decl(), RestApiConfig::decl(), LlmToolsConfig::decl(), DetectedTool::decl(), @@ -137,6 +134,10 @@ fn generate_typescript() -> String { CollectionResponse::decl(), HealthResponse::decl(), StatusResponse::decl(), + SectionDto::decl(), + SectionRowDto::decl(), + // Workflow export DTO + WorkflowExportResponse::decl(), // Skills DTOs SkillEntry::decl(), SkillsResponse::decl(), diff --git a/src/collections/backstage_full/ASSESS.json b/src/collections/full/ASSESS.json similarity index 81% rename from src/collections/backstage_full/ASSESS.json rename to src/collections/full/ASSESS.json index 12ccc27..4b2c467 100644 --- a/src/collections/backstage_full/ASSESS.json +++ b/src/collections/full/ASSESS.json @@ -7,7 +7,7 @@ "glyph": "~", "color": "magenta", "project_required": true, - "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for Backstage). Output ONLY the agent system prompt.", + "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -24,7 +24,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Assess project for Backstage catalog", + "placeholder": "Assess project structure", "max_length": 120, "display_order": 1 }, @@ -52,7 +52,7 @@ "name": "generate", "display_name": "Generating", "outputs": ["code"], - "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for Backstage UI):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", + "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", "allowed_tools": ["Read", "Write", "Edit"], "review_type": "plan", "on_reject": { diff --git a/src/collections/backstage_full/ASSESS.md b/src/collections/full/ASSESS.md similarity index 100% rename from src/collections/backstage_full/ASSESS.md rename to src/collections/full/ASSESS.md diff --git a/src/collections/backstage_full/FEAT.json b/src/collections/full/FEAT.json similarity index 100% rename from src/collections/backstage_full/FEAT.json rename to src/collections/full/FEAT.json diff --git a/src/collections/backstage_full/FEAT.md b/src/collections/full/FEAT.md similarity index 100% rename from src/collections/backstage_full/FEAT.md rename to src/collections/full/FEAT.md diff --git a/src/collections/backstage_full/FIX.json b/src/collections/full/FIX.json similarity index 100% rename from src/collections/backstage_full/FIX.json rename to src/collections/full/FIX.json diff --git a/src/collections/backstage_full/FIX.md b/src/collections/full/FIX.md similarity index 100% rename from src/collections/backstage_full/FIX.md rename to src/collections/full/FIX.md diff --git a/src/collections/backstage_full/INIT.json b/src/collections/full/INIT.json similarity index 52% rename from src/collections/backstage_full/INIT.json rename to src/collections/full/INIT.json index c324bc6..1c2bf38 100644 --- a/src/collections/backstage_full/INIT.json +++ b/src/collections/full/INIT.json @@ -1,13 +1,13 @@ { "$schema": "../../schemas/issuetype_schema.json", "key": "INIT", - "name": "Backstage Init", - "description": "Initialize Backstage deployment in workspace", + "name": "Workspace Init", + "description": "Initialize workspace structure", "mode": "paired", "glyph": "%", "color": "green", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage initialization. The agent scaffolds the Backstage directory structure in .tickets/operator/backstage/, configures app-config.yaml with project locations and guest authentication, and verifies Bun installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -29,7 +29,7 @@ }, { "name": "branding_name", - "description": "Custom branding name for Backstage", + "description": "Custom branding name", "type": "string", "required": false, "default": "", @@ -42,7 +42,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Initialize Backstage in workspace", + "placeholder": "Initialize workspace", "max_length": 120, "display_order": 3 } @@ -52,7 +52,7 @@ "name": "scaffold", "display_name": "Scaffolding", "outputs": ["code"], - "prompt": "Create the Backstage directory structure:\n\n1. Create `.tickets/operator/backstage/` directory\n2. Generate minimal `package.json` for Bun:\n ```json\n {\n \"name\": \"backstage-local\",\n \"scripts\": {\n \"dev\": \"backstage-cli dev\",\n \"start\": \"backstage-cli start\"\n }\n }\n ```\n3. Create `packages/` directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", + "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", "allowed_tools": ["Read", "Write", "Bash"], "next_step": "configure" }, @@ -60,7 +60,7 @@ "name": "configure", "display_name": "Configuring", "outputs": ["code"], - "prompt": "Configure Backstage for local development:\n\n1. Generate `app-config.yaml` with:\n - Guest authentication (no login required)\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", + "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", "allowed_tools": ["Read", "Write", "Glob"], "review_type": "plan", "on_reject": { @@ -73,7 +73,7 @@ "name": "verify", "display_name": "Verifying", "outputs": ["report"], - "prompt": "Verify the Backstage setup:\n\n1. Check Bun is installed: `bun --version`\n2. Install dependencies: `bun install`\n3. Verify configuration is valid\n4. Document setup in `.tickets/operator/backstage/README.md`\n5. Provide instructions for starting: `bun run dev`\n\nReport any issues that need manual resolution.", + "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/backstage_full/INIT.md b/src/collections/full/INIT.md similarity index 90% rename from src/collections/backstage_full/INIT.md rename to src/collections/full/INIT.md index f6fa5ed..05ef2b5 100644 --- a/src/collections/backstage_full/INIT.md +++ b/src/collections/full/INIT.md @@ -7,7 +7,7 @@ id: {{ id }} created: {{ created_datetime }} --- -# Backstage Init: {{ summary }} +# Workspace Init: {{ summary }} ## Workspace {{ workspace }} diff --git a/src/collections/backstage_full/INV.json b/src/collections/full/INV.json similarity index 100% rename from src/collections/backstage_full/INV.json rename to src/collections/full/INV.json diff --git a/src/collections/backstage_full/INV.md b/src/collections/full/INV.md similarity index 100% rename from src/collections/backstage_full/INV.md rename to src/collections/full/INV.md diff --git a/src/collections/backstage_full/SPIKE.json b/src/collections/full/SPIKE.json similarity index 100% rename from src/collections/backstage_full/SPIKE.json rename to src/collections/full/SPIKE.json diff --git a/src/collections/backstage_full/SPIKE.md b/src/collections/full/SPIKE.md similarity index 100% rename from src/collections/backstage_full/SPIKE.md rename to src/collections/full/SPIKE.md diff --git a/src/collections/backstage_full/SYNC.json b/src/collections/full/SYNC.json similarity index 70% rename from src/collections/backstage_full/SYNC.json rename to src/collections/full/SYNC.json index 996d791..519cd3b 100644 --- a/src/collections/backstage_full/SYNC.json +++ b/src/collections/full/SYNC.json @@ -2,12 +2,12 @@ "$schema": "../../schemas/issuetype_schema.json", "key": "SYNC", "name": "Catalog Sync", - "description": "Refresh Backstage catalog entries from projects", + "description": "Refresh catalog entries from projects", "mode": "autonomous", "glyph": "@", "color": "blue", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the Backstage schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -43,7 +43,7 @@ "name": "scan", "display_name": "Scanning", "outputs": ["report"], - "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in Backstage)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", + "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", "allowed_tools": ["Read", "Glob", "Grep", "Write"], "next_step": "validate" }, @@ -59,7 +59,7 @@ "name": "update", "display_name": "Updating", "outputs": ["report"], - "prompt": "Update the Backstage catalog:\n\n1. If Backstage server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", + "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/backstage_full/SYNC.md b/src/collections/full/SYNC.md similarity index 100% rename from src/collections/backstage_full/SYNC.md rename to src/collections/full/SYNC.md diff --git a/src/collections/backstage_full/TASK.json b/src/collections/full/TASK.json similarity index 100% rename from src/collections/backstage_full/TASK.json rename to src/collections/full/TASK.json diff --git a/src/collections/backstage_full/TASK.md b/src/collections/full/TASK.md similarity index 100% rename from src/collections/backstage_full/TASK.md rename to src/collections/full/TASK.md diff --git a/src/collections/backstage_full/collection.toml b/src/collections/full/collection.toml similarity index 58% rename from src/collections/backstage_full/collection.toml rename to src/collections/full/collection.toml index 09a8050..c2c576a 100644 --- a/src/collections/backstage_full/collection.toml +++ b/src/collections/full/collection.toml @@ -1,5 +1,5 @@ -name = "backstage_full" -description = "Full workflow plus Backstage: all types combined" +name = "full" +description = "Full workflow: all issue types combined" # Issue types in this collection (display order) types = ["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] diff --git a/src/collections/mod.rs b/src/collections/mod.rs index 5ec8ce1..059eb90 100644 --- a/src/collections/mod.rs +++ b/src/collections/mod.rs @@ -118,50 +118,50 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ }, ], }, - // Backstage Full collection: All 8 issuetypes + // Full collection: All 8 issuetypes EmbeddedCollection { - name: "backstage_full", - manifest: include_str!("backstage_full/collection.toml"), + name: "full", + manifest: include_str!("full/collection.toml"), issuetypes: &[ EmbeddedIssueType { key: "TASK", - schema_json: include_str!("backstage_full/TASK.json"), - template_md: include_str!("backstage_full/TASK.md"), + schema_json: include_str!("full/TASK.json"), + template_md: include_str!("full/TASK.md"), }, EmbeddedIssueType { key: "FEAT", - schema_json: include_str!("backstage_full/FEAT.json"), - template_md: include_str!("backstage_full/FEAT.md"), + schema_json: include_str!("full/FEAT.json"), + template_md: include_str!("full/FEAT.md"), }, EmbeddedIssueType { key: "FIX", - schema_json: include_str!("backstage_full/FIX.json"), - template_md: include_str!("backstage_full/FIX.md"), + schema_json: include_str!("full/FIX.json"), + template_md: include_str!("full/FIX.md"), }, EmbeddedIssueType { key: "SPIKE", - schema_json: include_str!("backstage_full/SPIKE.json"), - template_md: include_str!("backstage_full/SPIKE.md"), + schema_json: include_str!("full/SPIKE.json"), + template_md: include_str!("full/SPIKE.md"), }, EmbeddedIssueType { key: "INV", - schema_json: include_str!("backstage_full/INV.json"), - template_md: include_str!("backstage_full/INV.md"), + schema_json: include_str!("full/INV.json"), + template_md: include_str!("full/INV.md"), }, EmbeddedIssueType { key: "ASSESS", - schema_json: include_str!("backstage_full/ASSESS.json"), - template_md: include_str!("backstage_full/ASSESS.md"), + schema_json: include_str!("full/ASSESS.json"), + template_md: include_str!("full/ASSESS.md"), }, EmbeddedIssueType { key: "SYNC", - schema_json: include_str!("backstage_full/SYNC.json"), - template_md: include_str!("backstage_full/SYNC.md"), + schema_json: include_str!("full/SYNC.json"), + template_md: include_str!("full/SYNC.md"), }, EmbeddedIssueType { key: "INIT", - schema_json: include_str!("backstage_full/INIT.json"), - template_md: include_str!("backstage_full/INIT.md"), + schema_json: include_str!("full/INIT.json"), + template_md: include_str!("full/INIT.md"), }, ], }, @@ -238,8 +238,8 @@ mod tests { assert_eq!(operator.name, "operator"); assert_eq!(operator.issuetypes.len(), 5); - let full = get_embedded_collection("backstage_full").unwrap(); - assert_eq!(full.name, "backstage_full"); + let full = get_embedded_collection("full").unwrap(); + assert_eq!(full.name, "full"); assert_eq!(full.issuetypes.len(), 8); } @@ -255,7 +255,7 @@ mod tests { assert!(names.contains(&"dev_kanban")); assert!(names.contains(&"devops_kanban")); assert!(names.contains(&"operator")); - assert!(names.contains(&"backstage_full")); + assert!(names.contains(&"full")); } #[test] diff --git a/src/collections/operator/ASSESS.json b/src/collections/operator/ASSESS.json index 12ccc27..4b2c467 100644 --- a/src/collections/operator/ASSESS.json +++ b/src/collections/operator/ASSESS.json @@ -7,7 +7,7 @@ "glyph": "~", "color": "magenta", "project_required": true, - "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for Backstage). Output ONLY the agent system prompt.", + "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -24,7 +24,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Assess project for Backstage catalog", + "placeholder": "Assess project structure", "max_length": 120, "display_order": 1 }, @@ -52,7 +52,7 @@ "name": "generate", "display_name": "Generating", "outputs": ["code"], - "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for Backstage UI):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", + "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", "allowed_tools": ["Read", "Write", "Edit"], "review_type": "plan", "on_reject": { diff --git a/src/collections/operator/INIT.json b/src/collections/operator/INIT.json index c324bc6..1c2bf38 100644 --- a/src/collections/operator/INIT.json +++ b/src/collections/operator/INIT.json @@ -1,13 +1,13 @@ { "$schema": "../../schemas/issuetype_schema.json", "key": "INIT", - "name": "Backstage Init", - "description": "Initialize Backstage deployment in workspace", + "name": "Workspace Init", + "description": "Initialize workspace structure", "mode": "paired", "glyph": "%", "color": "green", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage initialization. The agent scaffolds the Backstage directory structure in .tickets/operator/backstage/, configures app-config.yaml with project locations and guest authentication, and verifies Bun installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -29,7 +29,7 @@ }, { "name": "branding_name", - "description": "Custom branding name for Backstage", + "description": "Custom branding name", "type": "string", "required": false, "default": "", @@ -42,7 +42,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Initialize Backstage in workspace", + "placeholder": "Initialize workspace", "max_length": 120, "display_order": 3 } @@ -52,7 +52,7 @@ "name": "scaffold", "display_name": "Scaffolding", "outputs": ["code"], - "prompt": "Create the Backstage directory structure:\n\n1. Create `.tickets/operator/backstage/` directory\n2. Generate minimal `package.json` for Bun:\n ```json\n {\n \"name\": \"backstage-local\",\n \"scripts\": {\n \"dev\": \"backstage-cli dev\",\n \"start\": \"backstage-cli start\"\n }\n }\n ```\n3. Create `packages/` directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", + "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", "allowed_tools": ["Read", "Write", "Bash"], "next_step": "configure" }, @@ -60,7 +60,7 @@ "name": "configure", "display_name": "Configuring", "outputs": ["code"], - "prompt": "Configure Backstage for local development:\n\n1. Generate `app-config.yaml` with:\n - Guest authentication (no login required)\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", + "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", "allowed_tools": ["Read", "Write", "Glob"], "review_type": "plan", "on_reject": { @@ -73,7 +73,7 @@ "name": "verify", "display_name": "Verifying", "outputs": ["report"], - "prompt": "Verify the Backstage setup:\n\n1. Check Bun is installed: `bun --version`\n2. Install dependencies: `bun install`\n3. Verify configuration is valid\n4. Document setup in `.tickets/operator/backstage/README.md`\n5. Provide instructions for starting: `bun run dev`\n\nReport any issues that need manual resolution.", + "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/operator/INIT.md b/src/collections/operator/INIT.md index f6fa5ed..05ef2b5 100644 --- a/src/collections/operator/INIT.md +++ b/src/collections/operator/INIT.md @@ -7,7 +7,7 @@ id: {{ id }} created: {{ created_datetime }} --- -# Backstage Init: {{ summary }} +# Workspace Init: {{ summary }} ## Workspace {{ workspace }} diff --git a/src/collections/operator/PROJECT-INIT.json b/src/collections/operator/PROJECT-INIT.json index 01129d8..66c7284 100644 --- a/src/collections/operator/PROJECT-INIT.json +++ b/src/collections/operator/PROJECT-INIT.json @@ -34,7 +34,7 @@ "name": "scan_structure", "display_name": "Scanning Structure", "outputs": ["report"], - "prompt": "Scan the project structure to understand what's needed:\n\n1. Check for existing configuration files\n2. Identify project type and language\n3. Find any missing required files:\n - CLAUDE.md (agent context)\n - catalog-info.yaml (Backstage catalog)\n - .tickets/ directory structure\n4. Document current state and gaps", + "prompt": "Scan the project structure to understand what's needed:\n\n1. Check for existing configuration files\n2. Identify project type and language\n3. Find any missing required files:\n - CLAUDE.md (agent context)\n - catalog-info.yaml (project catalog)\n - .tickets/ directory structure\n4. Document current state and gaps", "allowed_tools": ["Read", "Glob", "Grep"], "next_step": "apply_conventions" }, diff --git a/src/collections/operator/SYNC.json b/src/collections/operator/SYNC.json index 996d791..519cd3b 100644 --- a/src/collections/operator/SYNC.json +++ b/src/collections/operator/SYNC.json @@ -2,12 +2,12 @@ "$schema": "../../schemas/issuetype_schema.json", "key": "SYNC", "name": "Catalog Sync", - "description": "Refresh Backstage catalog entries from projects", + "description": "Refresh catalog entries from projects", "mode": "autonomous", "glyph": "@", "color": "blue", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the Backstage schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -43,7 +43,7 @@ "name": "scan", "display_name": "Scanning", "outputs": ["report"], - "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in Backstage)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", + "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", "allowed_tools": ["Read", "Glob", "Grep", "Write"], "next_step": "validate" }, @@ -59,7 +59,7 @@ "name": "update", "display_name": "Updating", "outputs": ["report"], - "prompt": "Update the Backstage catalog:\n\n1. If Backstage server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", + "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/operator/collection.toml b/src/collections/operator/collection.toml index 3c52e1c..61dcc99 100644 --- a/src/collections/operator/collection.toml +++ b/src/collections/operator/collection.toml @@ -1,5 +1,5 @@ name = "operator" -description = "Operator Backstage tasks: ASSESS, SYNC, INIT" +description = "Operator automation tasks: ASSESS, SYNC, INIT" # Issue types in this collection (display order) types = ["ASSESS", "SYNC", "INIT"] diff --git a/src/config.rs b/src/config.rs index 968d6ed..dd2a577 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,3 @@ -#[path = "config/backstage_config.rs"] -pub mod backstage_config; #[path = "config/git_config.rs"] pub mod git_config; #[path = "config/kanban.rs"] @@ -11,7 +9,6 @@ pub mod notifications_config; #[path = "config/sessions.rs"] pub mod sessions; -pub use backstage_config::*; pub use git_config::*; pub use kanban::*; pub use llm_tools::*; @@ -50,8 +47,6 @@ pub struct Config { #[serde(default)] pub llm_tools: LlmToolsConfig, #[serde(default)] - pub backstage: BackstageConfig, - #[serde(default)] pub rest_api: RestApiConfig, #[serde(default)] pub git: GitConfig, @@ -71,6 +66,12 @@ pub struct Config { /// Relay MCP injection configuration #[serde(default)] pub relay: RelayConfig, + /// Model Context Protocol (MCP) server configuration + #[serde(default)] + pub mcp: McpConfig, + /// Agent Client Protocol (ACP) agent configuration + #[serde(default)] + pub acp: AcpConfig, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -78,6 +79,10 @@ pub struct Config { pub struct AgentsConfig { pub max_parallel: usize, pub cores_reserved: usize, + /// Maximum concurrent agents per project/repo (default: 1). + /// Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + #[serde(default = "default_max_agents_per_repo")] + pub max_agents_per_repo: usize, pub health_check_interval: u64, /// Timeout in seconds for each agent generation (default: 300 = 5 min) #[serde(default = "default_generation_timeout")] @@ -93,6 +98,10 @@ pub struct AgentsConfig { pub silence_threshold: u64, } +fn default_max_agents_per_repo() -> usize { + 1 +} + fn default_generation_timeout() -> u64 { 300 // 5 minutes } @@ -290,6 +299,118 @@ impl Default for RestApiConfig { } } +/// Model Context Protocol (MCP) server configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export)] +pub struct McpConfig { + /// Whether to mount MCP HTTP/SSE endpoints on the REST API server. + /// Toggling requires an API restart (no hot-swap of the axum router). + #[serde(default = "default_true")] + pub http_enabled: bool, + /// Whether the descriptor endpoint advertises the `operator mcp` stdio + /// command. Set to false on multi-tenant/remote deployments where clients + /// shouldn't spawn local subprocesses. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + /// create) over MCP. Defaults to `false` because any MCP client can call them. + #[serde(default)] + pub expose_ticket_write_tools: bool, + /// External MCP servers to inject into spawned agent sessions. + /// Each entry produces a separate `--mcp-config` file alongside the + /// relay config when launching Claude Code agents. + #[serde(default)] + pub external_servers: Vec, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + http_enabled: true, + stdio_advertised: true, + expose_ticket_write_tools: false, + external_servers: Vec::new(), + } + } +} + +/// An external MCP server to inject into spawned agent sessions. +/// +/// Values in `command`, `args`, and `env` support `${VAR}` interpolation, +/// expanded at spawn time from the operator process environment. +/// +/// When `discover_from` is set, operator reads an MCP server spec from that +/// JSON sidecar file at spawn time. The sidecar must contain a top-level +/// `mcpServer` object with `command`, `args`, and `env` fields. If the file +/// is absent and `command` is empty, the server is silently skipped. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ExternalMcpServer { + /// Server name used as the key in the `mcpServers` JSON object + /// (e.g., "kanbots"). Must be unique across all external servers. + pub name: String, + /// Command to execute. Supports `${VAR}` interpolation. + #[serde(default)] + pub command: String, + /// Command arguments. Each element supports `${VAR}` interpolation. + #[serde(default)] + pub args: Vec, + /// Environment variables passed to the MCP server process. + /// Values support `${VAR}` interpolation. + #[serde(default)] + pub env: std::collections::HashMap, + /// Whether this server is enabled. Allows disabling without removing config. + #[serde(default = "default_true")] + pub enabled: bool, + /// Path to a JSON sidecar discovery file. Relative paths resolve from + /// the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. + /// When the file exists, its `mcpServer` spec is used verbatim (overriding + /// `command`/`args`/`env`). When absent and `command` is empty, the server + /// is silently skipped. + #[serde(default)] + pub discover_from: Option, +} + +/// Agent Client Protocol (ACP) agent configuration. +/// +/// Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`, +/// Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session +/// maps to an in-progress ACP ticket and a delegator subprocess. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export)] +pub struct AcpConfig { + /// Whether the dashboard advertises the `operator acp` stdio entrypoint + /// (and editor-config snippet actions). Set to false on machines that + /// shouldn't be used as ACP agents. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Name of the delegator (from `[[delegators]]`) to use for ACP prompts. + /// If unset or not found, falls back to the operator's default delegator + /// resolution. + #[serde(default)] + pub default_delegator: Option, + /// Maximum number of concurrent ACP sessions. New `session/new` requests + /// beyond this limit are rejected with a JSON-RPC error. + #[serde(default = "default_acp_max_sessions")] + pub max_concurrent_sessions: usize, +} + +impl Default for AcpConfig { + fn default() -> Self { + Self { + stdio_advertised: true, + default_delegator: None, + max_concurrent_sessions: default_acp_max_sessions(), + } + } +} + +fn default_acp_max_sessions() -> usize { + 8 +} + /// Predefined issue type collections #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -527,7 +648,7 @@ impl Config { ); let config = builder.build().context("Failed to load configuration")?; - config.try_deserialize().map_err(|e| { + let cfg: Self = config.try_deserialize().map_err(|e| { let mut sources = vec![]; let operator_config = Self::operator_config_path(); if operator_config.exists() { @@ -550,7 +671,17 @@ impl Config { anyhow::anyhow!( "Failed to deserialize configuration: {e}\n\nConfig files loaded:\n{sources_str}\n\nCheck these files for missing or invalid fields." ) - }) + })?; + + if cfg.agents.max_agents_per_repo > 1 && !cfg.git.use_worktrees { + tracing::warn!( + max_agents_per_repo = cfg.agents.max_agents_per_repo, + "max_agents_per_repo > 1 without git.use_worktrees = true; \ + multiple agents on the same repo without worktrees will cause git conflicts" + ); + } + + Ok(cfg) } /// Save config to .tickets/operator/config.toml @@ -578,6 +709,10 @@ impl Config { self.agents.max_parallel.min(core_based_max).max(1) } + pub fn effective_max_agents_per_repo(&self) -> usize { + self.agents.max_agents_per_repo.max(1) + } + /// Get absolute path to tickets directory pub fn tickets_path(&self) -> PathBuf { let path = PathBuf::from(&self.paths.tickets); @@ -609,7 +744,6 @@ impl Config { } /// Get absolute path to worktrees directory - #[allow(dead_code)] // Will be used when WorktreeManager is wired into launcher pub fn worktrees_path(&self) -> PathBuf { let path = PathBuf::from(&self.paths.worktrees); if path.is_absolute() { @@ -634,17 +768,6 @@ impl Config { self.tickets_path().join("operator").join("tmux-status.sh") } - /// Get absolute path to Backstage installation directory - pub fn backstage_path(&self) -> PathBuf { - self.state_path().join(&self.backstage.subpath) - } - - /// Get absolute path to Backstage branding directory - #[allow(dead_code)] // For future branding customization support - pub fn backstage_branding_path(&self) -> PathBuf { - self.backstage_path().join(&self.backstage.branding_subpath) - } - /// Get priority index for a ticket type (lower = higher priority) pub fn priority_index(&self, ticket_type: &str) -> usize { self.queue @@ -664,7 +787,6 @@ impl Config { /// Returns projects found by scanning for .git directories and LLM marker files. /// Each project includes git repo info (remote URL, default branch, GitHub info) /// and a list of available LLM tools. - #[allow(dead_code)] // For future integration pub fn discover_projects_full(&self) -> Vec { crate::projects::discover_projects_with_git(&self.projects_path()) } @@ -677,6 +799,7 @@ impl Default for Config { agents: AgentsConfig { max_parallel: 5, cores_reserved: 1, + max_agents_per_repo: 1, health_check_interval: 30, generation_timeout_secs: 300, // 5 minutes sync_interval: 60, // 1 minute @@ -720,7 +843,6 @@ impl Default for Config { tmux: TmuxConfig::default(), sessions: SessionsConfig::default(), llm_tools: LlmToolsConfig::default(), - backstage: BackstageConfig::default(), rest_api: RestApiConfig::default(), git: GitConfig::default(), kanban: KanbanConfig::default(), @@ -728,6 +850,8 @@ impl Default for Config { delegators: Vec::new(), model_servers: Vec::new(), relay: RelayConfig::default(), + mcp: McpConfig::default(), + acp: AcpConfig::default(), } } } diff --git a/src/config/backstage_config.rs b/src/config/backstage_config.rs deleted file mode 100644 index a39aa98..0000000 --- a/src/config/backstage_config.rs +++ /dev/null @@ -1,164 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -/// Backstage integration configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct BackstageConfig { - /// Whether Backstage integration is enabled - #[serde(default = "default_backstage_enabled")] - pub enabled: bool, - /// Whether to show Backstage in the Connections status section - #[serde(default)] - pub display: bool, - /// Port for the Backstage server - #[serde(default = "default_backstage_port")] - pub port: u16, - /// Auto-start Backstage server when TUI launches - #[serde(default)] - pub auto_start: bool, - /// Subdirectory within `state_path` for Backstage installation - #[serde(default = "default_backstage_subpath")] - pub subpath: String, - /// Subdirectory within backstage path for branding customization - #[serde(default = "default_branding_subpath")] - pub branding_subpath: String, - /// Base URL for downloading backstage-server binary - #[serde(default = "default_backstage_release_url")] - pub release_url: String, - /// Optional local path to backstage-server binary - /// If set, this is used instead of downloading from `release_url` - #[serde(default)] - pub local_binary_path: Option, - /// Branding and theming configuration - #[serde(default)] - pub branding: BrandingConfig, -} - -/// Branding configuration for Backstage portal -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct BrandingConfig { - /// App title shown in header - #[serde(default = "default_app_title")] - pub app_title: String, - /// Organization name - #[serde(default = "default_org_name")] - pub org_name: String, - /// Path to logo SVG (relative to branding path) - #[serde(default)] - pub logo_path: Option, - /// Theme colors (uses Operator defaults if not set) - #[serde(default)] - pub colors: ThemeColors, -} - -fn default_app_title() -> String { - "Operator Portal".to_string() -} - -fn default_org_name() -> String { - "Operator".to_string() -} - -impl Default for BrandingConfig { - fn default() -> Self { - Self { - app_title: default_app_title(), - org_name: default_org_name(), - logo_path: Some("logo.svg".to_string()), - colors: ThemeColors::default(), - } - } -} - -/// Theme color configuration for Backstage -/// Default colors match Operator's tmux theme -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct ThemeColors { - /// Primary/accent color (default: salmon #cc6c55) - #[serde(default = "default_color_primary")] - pub primary: String, - /// Secondary color (default: dark teal #114145) - #[serde(default = "default_color_secondary")] - pub secondary: String, - /// Accent/highlight color (default: cream #f4dbb7) - #[serde(default = "default_color_accent")] - pub accent: String, - /// Warning/error color (default: coral #d46048) - #[serde(default = "default_color_warning")] - pub warning: String, - /// Muted text color (default: darker salmon #8a4a3a) - #[serde(default = "default_color_muted")] - pub muted: String, -} - -fn default_color_primary() -> String { - "#cc6c55".to_string() // salmon -} - -fn default_color_secondary() -> String { - "#114145".to_string() // dark teal -} - -fn default_color_accent() -> String { - "#f4dbb7".to_string() // cream -} - -fn default_color_warning() -> String { - "#d46048".to_string() // coral -} - -fn default_color_muted() -> String { - "#8a4a3a".to_string() // darker salmon -} - -impl Default for ThemeColors { - fn default() -> Self { - Self { - primary: default_color_primary(), - secondary: default_color_secondary(), - accent: default_color_accent(), - warning: default_color_warning(), - muted: default_color_muted(), - } - } -} - -fn default_backstage_enabled() -> bool { - true -} - -fn default_backstage_port() -> u16 { - 7007 -} - -fn default_backstage_subpath() -> String { - "backstage".to_string() -} - -fn default_branding_subpath() -> String { - "branding".to_string() -} - -fn default_backstage_release_url() -> String { - "https://github.com/untra/operator/releases/latest/download".to_string() -} - -impl Default for BackstageConfig { - fn default() -> Self { - Self { - enabled: default_backstage_enabled(), - display: false, - port: default_backstage_port(), - auto_start: false, - subpath: default_backstage_subpath(), - branding_subpath: default_branding_subpath(), - release_url: default_backstage_release_url(), - local_binary_path: None, - branding: BrandingConfig::default(), - } - } -} diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index 5c78471..45f0932 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -221,6 +221,28 @@ fn test_effective_max_agents_reserves_cores() { assert!(effective <= cpu_count.saturating_sub(config.agents.cores_reserved)); } +// --- effective_max_agents_per_repo tests --- + +#[test] +fn test_effective_max_agents_per_repo_default() { + let config = Config::default(); + assert_eq!(config.effective_max_agents_per_repo(), 1); +} + +#[test] +fn test_effective_max_agents_per_repo_clamps_zero() { + let mut config = Config::default(); + config.agents.max_agents_per_repo = 0; + assert_eq!(config.effective_max_agents_per_repo(), 1); +} + +#[test] +fn test_effective_max_agents_per_repo_custom() { + let mut config = Config::default(); + config.agents.max_agents_per_repo = 3; + assert_eq!(config.effective_max_agents_per_repo(), 3); +} + // --- Path resolution tests --- #[test] @@ -532,6 +554,119 @@ fn test_relay_config_default_auto_inject_is_false() { assert!(!config.relay.auto_inject_mcp); } +// --- ExternalMcpServer tests --- + +#[test] +fn test_mcp_external_servers_defaults_to_empty() { + let config = McpConfig::default(); + assert!(config.external_servers.is_empty()); +} + +#[test] +fn test_mcp_config_without_external_servers_still_parses() { + let toml_str = r" + http_enabled = true + stdio_advertised = false + expose_ticket_write_tools = true + "; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert!(config.http_enabled); + assert!(!config.stdio_advertised); + assert!(config.expose_ticket_write_tools); + assert!(config.external_servers.is_empty()); +} + +#[test] +fn test_external_mcp_server_static_config_roundtrip() { + let toml_str = r#" + [[external_servers]] + name = "my-tools" + command = "/usr/local/bin/my-mcp-server" + args = ["--stdio"] + env = { API_KEY = "${MY_TOOLS_API_KEY}" } + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.external_servers.len(), 1); + let server = &config.external_servers[0]; + assert_eq!(server.name, "my-tools"); + assert_eq!(server.command, "/usr/local/bin/my-mcp-server"); + assert_eq!(server.args, vec!["--stdio"]); + assert_eq!(server.env.get("API_KEY").unwrap(), "${MY_TOOLS_API_KEY}"); + assert!(server.enabled); + assert!(server.discover_from.is_none()); +} + +#[test] +fn test_external_mcp_server_sidecar_config_roundtrip() { + let toml_str = r#" + [[external_servers]] + name = "kanbots" + command = "" + discover_from = ".kanbots/active-session.json" + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.external_servers.len(), 1); + let server = &config.external_servers[0]; + assert_eq!(server.name, "kanbots"); + assert_eq!(server.command, ""); + assert_eq!( + server.discover_from.as_deref(), + Some(".kanbots/active-session.json") + ); + assert!(server.enabled); +} + +#[test] +fn test_external_mcp_server_disabled() { + let toml_str = r#" + [[external_servers]] + name = "disabled-server" + command = "some-binary" + enabled = false + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.external_servers[0].enabled); +} + +#[test] +fn test_external_mcp_server_multiple_servers() { + let toml_str = r#" + [[external_servers]] + name = "kanbots" + command = "" + discover_from = ".kanbots/active-session.json" + + [[external_servers]] + name = "my-tools" + command = "/usr/local/bin/my-mcp" + args = ["--port", "9090"] + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.external_servers.len(), 2); + assert_eq!(config.external_servers[0].name, "kanbots"); + assert_eq!(config.external_servers[1].name, "my-tools"); +} + +#[test] +fn test_external_mcp_server_json_serde_roundtrip() { + let server = ExternalMcpServer { + name: "test".to_string(), + command: "/bin/test".to_string(), + args: vec!["--flag".to_string()], + env: std::collections::HashMap::from([("KEY".to_string(), "val".to_string())]), + enabled: true, + discover_from: Some("/tmp/sidecar.json".to_string()), + }; + let json = serde_json::to_string(&server).unwrap(); + let parsed: ExternalMcpServer = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "test"); + assert_eq!(parsed.command, "/bin/test"); + assert_eq!(parsed.args, vec!["--flag"]); + assert_eq!(parsed.env.get("KEY").unwrap(), "val"); + assert!(parsed.enabled); + assert_eq!(parsed.discover_from.as_deref(), Some("/tmp/sidecar.json")); +} + #[test] fn test_delegator_launch_config_operator_relay_defaults_to_none() { let toml_str = r#" diff --git a/src/config/kanban.rs b/src/config/kanban.rs index da6827a..d35b4a0 100644 --- a/src/config/kanban.rs +++ b/src/config/kanban.rs @@ -232,7 +232,7 @@ impl KanbanConfig { /// /// Delegates to the provider-specific upsert method based on the /// `WorkspaceExtra` variant in the validated workspace. - #[allow(dead_code)] // Will be used by onboarding service in Phase 1b + #[allow(dead_code)] // Used in tests pub fn upsert_project( &mut self, workspace: &crate::api::providers::kanban::ValidatedWorkspace, diff --git a/src/docs_gen/config.rs b/src/docs_gen/config.rs index 80b0ed2..fa0c264 100644 --- a/src/docs_gen/config.rs +++ b/src/docs_gen/config.rs @@ -69,11 +69,6 @@ const CONFIG_SECTIONS: &[ConfigSection] = &[ schema_name: "TmuxConfig", description: "Tmux integration settings", }, - ConfigSection { - name: "backstage", - schema_name: "BackstageConfig", - description: "Backstage server integration", - }, ConfigSection { name: "llm_tools", schema_name: "LlmToolsConfig", @@ -338,14 +333,6 @@ impl ConfigDocGenerator { "to_file" => Some(config.logging.to_file.to_string()), _ => None, }, - "backstage" => match field { - "enabled" => Some(config.backstage.enabled.to_string()), - "port" => Some(config.backstage.port.to_string()), - "auto_start" => Some(config.backstage.auto_start.to_string()), - "subpath" => Some(config.backstage.subpath), - "branding_subpath" => Some(config.backstage.branding_subpath), - _ => None, - }, _ => None, }; @@ -381,7 +368,7 @@ impl ConfigDocGenerator { output.push_str("**Examples**:\n"); output.push_str("- `OPERATOR_AGENTS__MAX_PARALLEL=2`\n"); output.push_str("- `OPERATOR_LOGGING__LEVEL=debug`\n"); - output.push_str("- `OPERATOR_BACKSTAGE__PORT=8080`\n\n"); + output.push_str("- `OPERATOR_TMUX__ENABLED=true`\n\n"); output } @@ -411,8 +398,6 @@ mod tests { assert!(result.contains("## `[notifications]`")); assert!(result.contains("## `[queue]`")); assert!(result.contains("## `[paths]`")); - assert!(result.contains("## `[backstage]`")); - // Should have example config assert!(result.contains("## Example Configuration")); assert!(result.contains("```toml")); diff --git a/src/docs_gen/project_analysis_schema.rs b/src/docs_gen/project_analysis_schema.rs index 3cddd1e..4dde98d 100644 --- a/src/docs_gen/project_analysis_schema.rs +++ b/src/docs_gen/project_analysis_schema.rs @@ -4,7 +4,7 @@ //! via schemars, making Rust the single source of truth for structured output. use super::DocGenerator; -use crate::backstage::analyzer::ProjectAnalysis; +use crate::taxonomy::analyzer::ProjectAnalysis; use anyhow::Result; use schemars::schema_for; @@ -17,7 +17,7 @@ impl DocGenerator for ProjectAnalysisSchemaDocGenerator { } fn source(&self) -> &'static str { - "src/backstage/analyzer.rs (ProjectAnalysis)" + "src/taxonomy/analyzer.rs (ProjectAnalysis)" } fn output_path(&self) -> &'static str { @@ -43,7 +43,7 @@ impl DocGenerator for ProjectAnalysisSchemaDocGenerator { obj.insert( "$comment".to_string(), serde_json::Value::String( - "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema".to_string(), + "AUTO-GENERATED FROM src/taxonomy/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema".to_string(), ), ); } diff --git a/src/docs_gen/taxonomy.rs b/src/docs_gen/taxonomy.rs index f98f5c3..995d1ea 100644 --- a/src/docs_gen/taxonomy.rs +++ b/src/docs_gen/taxonomy.rs @@ -2,7 +2,7 @@ use super::markdown::{bold, bullet_list, heading, inline_code, table}; use super::{format_header, DocGenerator}; -use crate::backstage::taxonomy::{KindTier, Taxonomy}; +use crate::taxonomy::{KindTier, Taxonomy}; use anyhow::Result; /// Generates taxonomy documentation from taxonomy.toml @@ -14,11 +14,11 @@ impl DocGenerator for TaxonomyDocGenerator { } fn source(&self) -> &'static str { - "src/backstage/taxonomy.toml" + "src/taxonomy/taxonomy.toml" } fn output_path(&self) -> &'static str { - "backstage/taxonomy.md" + "taxonomy/index.md" } fn generate(&self) -> Result { @@ -33,7 +33,7 @@ impl DocGenerator for TaxonomyDocGenerator { taxonomy.tiers.len() )); output.push_str( - "Each Kind represents a category of project that can be cataloged in Backstage. ", + "Each Kind represents a category of project that can be classified by Operator. ", ); output.push_str("The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files.\n\n"); @@ -63,8 +63,8 @@ impl DocGenerator for TaxonomyDocGenerator { // File pattern reference output.push_str(&self.generate_pattern_reference(taxonomy)); - // Backstage type mapping - output.push_str(&self.generate_backstage_mapping(taxonomy)); + // Catalog type mapping + output.push_str(&self.generate_catalog_type_mapping(taxonomy)); Ok(output) } @@ -72,7 +72,7 @@ impl DocGenerator for TaxonomyDocGenerator { impl TaxonomyDocGenerator { fn generate_summary_table(&self, taxonomy: &Taxonomy) -> String { - let headers = &["ID", "Key", "Name", "Tier", "Backstage Type"]; + let headers = &["ID", "Key", "Name", "Tier", "Catalog Type"]; let rows: Vec> = taxonomy .kinds .iter() @@ -82,7 +82,7 @@ impl TaxonomyDocGenerator { inline_code(&k.key), k.name.clone(), k.tier.clone(), - inline_code(&k.backstage_type), + inline_code(&k.catalog_type), ] }) .collect(); @@ -136,8 +136,8 @@ impl TaxonomyDocGenerator { format!("{}: {}", bold("Primary Output"), &kind.output), format!( "{}: {}", - bold("Backstage Type"), - inline_code(&kind.backstage_type) + bold("Catalog Type"), + inline_code(&kind.catalog_type) ), ]; output.push_str(&bullet_list(&details)); @@ -181,17 +181,17 @@ impl TaxonomyDocGenerator { output } - fn generate_backstage_mapping(&self, taxonomy: &Taxonomy) -> String { - let mut output = heading(2, "Backstage Type Mapping"); - output.push_str("Each Kind maps to a Backstage catalog type:\n\n"); + fn generate_catalog_type_mapping(&self, taxonomy: &Taxonomy) -> String { + let mut output = heading(2, "Catalog Type Mapping"); + output.push_str("Each Kind maps to a catalog type:\n\n"); - let headers = &["Backstage Type", "Kinds"]; + let headers = &["Catalog Type", "Kinds"]; let mut type_map: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new(); for kind in &taxonomy.kinds { type_map - .entry(&kind.backstage_type) + .entry(&kind.catalog_type) .or_default() .push(&kind.key); } @@ -210,7 +210,7 @@ impl TaxonomyDocGenerator { }) .collect(); - // Sort by backstage type + // Sort by catalog type rows.sort_by(|a, b| a[0].cmp(&b[0])); output.push_str(&table(headers, &rows)); diff --git a/src/env_vars.rs b/src/env_vars.rs index ef6aee6..45e9256 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -44,8 +44,6 @@ pub enum EnvVarCategory { Launch, /// Tmux integration Tmux, - /// Backstage server settings - Backstage, /// LLM tool allowlist/denylist LlmTools, /// Logging configuration @@ -64,7 +62,6 @@ impl EnvVarCategory { EnvVarCategory::Ui => "UI", EnvVarCategory::Launch => "Launch", EnvVarCategory::Tmux => "Tmux", - EnvVarCategory::Backstage => "Backstage", EnvVarCategory::LlmTools => "LLM Tools", EnvVarCategory::Logging => "Logging", } @@ -81,7 +78,6 @@ impl EnvVarCategory { EnvVarCategory::Ui, EnvVarCategory::Launch, EnvVarCategory::Tmux, - EnvVarCategory::Backstage, EnvVarCategory::LlmTools, EnvVarCategory::Logging, ] @@ -306,23 +302,6 @@ pub static ENV_VARS: &[EnvVar] = &[ default: Some("operator"), example: Some("agent"), }, - // === Backstage === - EnvVar { - name: "OPERATOR_BACKSTAGE__PORT", - description: "Port for the Backstage web server", - category: EnvVarCategory::Backstage, - required: false, - default: Some("3000"), - example: Some("8080"), - }, - EnvVar { - name: "OPERATOR_BACKSTAGE__AUTO_START", - description: "Automatically start Backstage server with TUI", - category: EnvVarCategory::Backstage, - required: false, - default: Some("false"), - example: Some("true"), - }, // === LLM Tools === EnvVar { name: "OPERATOR_LLM_TOOLS__ENABLED", @@ -442,7 +421,6 @@ mod tests { assert_eq!(EnvVarCategory::Ui.display_name(), "UI"); assert_eq!(EnvVarCategory::Launch.display_name(), "Launch"); assert_eq!(EnvVarCategory::Tmux.display_name(), "Tmux"); - assert_eq!(EnvVarCategory::Backstage.display_name(), "Backstage"); assert_eq!(EnvVarCategory::LlmTools.display_name(), "LLM Tools"); assert_eq!(EnvVarCategory::Logging.display_name(), "Logging"); } @@ -450,8 +428,8 @@ mod tests { #[test] fn test_all_categories_in_order() { let all = EnvVarCategory::all(); - assert_eq!(all.len(), 11); + assert_eq!(all.len(), 10); assert_eq!(all[0], EnvVarCategory::Authentication); - assert_eq!(all[10], EnvVarCategory::Logging); + assert_eq!(all[9], EnvVarCategory::Logging); } } diff --git a/src/integrations/inventory.rs b/src/integrations/inventory.rs new file mode 100644 index 0000000..ca41090 --- /dev/null +++ b/src/integrations/inventory.rs @@ -0,0 +1,165 @@ +//! Capability inventory for surface parity testing. +//! +//! Defines every operator capability and which surfaces expose it: +//! slash commands (Zed), MCP tools, REST routes, and TUI keybindings. + +/// A single operator capability and the surfaces where it appears. +#[derive(Debug, Clone)] +pub struct Capability { + /// Human-readable capability name + pub name: &'static str, + /// Zed slash command key (e.g. "op-pause"), or None if not exposed + pub slash_command: Option<&'static str>, + /// MCP tool name (e.g. "`operator_pause_queue`"), or None if not exposed + pub mcp_tool: Option<&'static str>, + /// REST endpoint in "METHOD /path" form (axum-style params), or None + pub rest_endpoint: Option<&'static str>, + /// TUI keybinding description substring to match, or None + pub tui_action: Option<&'static str>, +} + +/// Returns the full list of operator capabilities with their surface mappings. +/// +/// Each entry declares which surfaces expose that capability. A `None` value +/// means the capability is intentionally absent from that surface. +pub fn all_capabilities() -> Vec { + vec![ + Capability { + name: "Status", + slash_command: Some("op-status"), + mcp_tool: Some("operator_status"), + rest_endpoint: Some("GET /api/v1/status"), + tui_action: None, // status panel is a view, not a keybinding action + }, + Capability { + name: "List Queue", + slash_command: Some("op-queue"), + mcp_tool: Some("operator_list_tickets"), + rest_endpoint: Some("GET /api/v1/queue/kanban"), + tui_action: Some("Focus Queue panel"), + }, + Capability { + name: "Launch Ticket", + slash_command: Some("op-launch"), + mcp_tool: Some("operator_launch_ticket"), + rest_endpoint: Some("POST /api/v1/tickets/:id/launch"), + tui_action: Some("Launch selected ticket"), + }, + Capability { + name: "Active Agents", + slash_command: Some("op-active"), + mcp_tool: None, + rest_endpoint: Some("GET /api/v1/agents/active"), + tui_action: Some("Focus Agents panel"), + }, + Capability { + name: "Completed Tickets", + slash_command: Some("op-completed"), + mcp_tool: Some("operator_list_tickets"), + rest_endpoint: None, + tui_action: None, + }, + Capability { + name: "Ticket Details", + slash_command: Some("op-ticket"), + mcp_tool: None, + rest_endpoint: Some("GET /api/v1/tickets/:id"), + tui_action: None, + }, + Capability { + name: "Pause Queue", + slash_command: Some("op-pause"), + mcp_tool: Some("operator_pause_queue"), + rest_endpoint: Some("POST /api/v1/queue/pause"), + tui_action: Some("Pause queue"), + }, + Capability { + name: "Resume Queue", + slash_command: Some("op-resume"), + mcp_tool: Some("operator_resume_queue"), + rest_endpoint: Some("POST /api/v1/queue/resume"), + tui_action: Some("Resume queue"), + }, + Capability { + name: "Sync Kanban", + slash_command: Some("op-sync"), + mcp_tool: Some("operator_sync_kanban"), + rest_endpoint: Some("POST /api/v1/queue/sync"), + tui_action: Some("Sync kanban"), + }, + Capability { + name: "Approve Review", + slash_command: Some("op-approve"), + mcp_tool: Some("operator_approve_agent"), + rest_endpoint: Some("POST /api/v1/agents/:agent_id/approve"), + tui_action: Some("Approve review"), + }, + Capability { + name: "Reject Review", + slash_command: Some("op-reject"), + mcp_tool: Some("operator_reject_agent"), + rest_endpoint: Some("POST /api/v1/agents/:agent_id/reject"), + tui_action: Some("Reject review"), + }, + Capability { + name: "Setup Agent", + slash_command: Some("op-setup-agent"), + mcp_tool: None, + rest_endpoint: None, + tui_action: None, // Zed-only capability + }, + Capability { + name: "Setup", + slash_command: Some("op-setup"), + mcp_tool: None, + rest_endpoint: None, + tui_action: None, // Zed-only diagnostic command + }, + Capability { + name: "Help", + slash_command: Some("op-help"), + mcp_tool: None, + rest_endpoint: None, + tui_action: None, // Zed-only help listing + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_capabilities_non_empty() { + let caps = all_capabilities(); + assert!(!caps.is_empty(), "Capability list should not be empty"); + } + + #[test] + fn test_all_capabilities_have_names() { + for cap in all_capabilities() { + assert!(!cap.name.is_empty(), "Every capability must have a name"); + } + } + + #[test] + fn test_all_capabilities_have_at_least_one_surface() { + for cap in all_capabilities() { + let has_any = cap.slash_command.is_some() + || cap.mcp_tool.is_some() + || cap.rest_endpoint.is_some() + || cap.tui_action.is_some(); + assert!( + has_any, + "Capability '{}' must appear on at least one surface", + cap.name + ); + } + } + + #[test] + fn test_capability_count() { + let caps = all_capabilities(); + assert_eq!(caps.len(), 14, "Expected 14 capabilities in the inventory"); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 0000000..0164a92 --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,9 @@ +//! Integration surface inventory. +//! +//! Re-exports the capability inventory used by surface parity tests +//! to ensure slash commands, MCP tools, REST routes, and TUI actions +//! stay aligned. + +pub mod inventory; + +pub use inventory::{all_capabilities, Capability}; diff --git a/src/issuetypes/collection.rs b/src/issuetypes/collection.rs index 2798870..6dc1f3d 100644 --- a/src/issuetypes/collection.rs +++ b/src/issuetypes/collection.rs @@ -98,10 +98,10 @@ pub enum BuiltinPreset { DevKanban, /// DevOps Kanban: TASK, SPIKE, INV, FEAT, FIX DevopsKanban, - /// Operator: ASSESS, SYNC, INIT (Backstage operations) + /// Operator: ASSESS, SYNC, INIT (automation operations) Operator, - /// Backstage Full: DevOps + Operator types - BackstageFull, + /// Full: DevOps + Operator types + Full, } impl BuiltinPreset { @@ -112,7 +112,7 @@ impl BuiltinPreset { BuiltinPreset::DevKanban, BuiltinPreset::DevopsKanban, BuiltinPreset::Operator, - BuiltinPreset::BackstageFull, + BuiltinPreset::Full, ] } @@ -123,7 +123,7 @@ impl BuiltinPreset { BuiltinPreset::DevKanban => "dev_kanban", BuiltinPreset::DevopsKanban => "devops_kanban", BuiltinPreset::Operator => "operator", - BuiltinPreset::BackstageFull => "backstage_full", + BuiltinPreset::Full => "full", } } @@ -133,8 +133,8 @@ impl BuiltinPreset { BuiltinPreset::Simple => "Simple workflow with TASK only", BuiltinPreset::DevKanban => "Developer kanban with TASK, FEAT, FIX", BuiltinPreset::DevopsKanban => "DevOps kanban with TASK, SPIKE, INV, FEAT, FIX", - BuiltinPreset::Operator => "Operator Backstage tasks: ASSESS, SYNC, INIT", - BuiltinPreset::BackstageFull => "Full workflow plus Backstage: all types combined", + BuiltinPreset::Operator => "Operator automation tasks: ASSESS, SYNC, INIT", + BuiltinPreset::Full => "Full workflow: all types combined", } } @@ -152,8 +152,8 @@ impl BuiltinPreset { } BuiltinPreset::Operator => IssueTypeCollection::new("operator", self.description()) .with_types(["ASSESS", "SYNC", "INIT"]), - BuiltinPreset::BackstageFull => { - IssueTypeCollection::new("backstage_full", self.description()).with_types([ + BuiltinPreset::Full => { + IssueTypeCollection::new("full", self.description()).with_types([ "TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT", ]) } @@ -167,7 +167,7 @@ impl BuiltinPreset { "dev_kanban" | "devkanban" => Some(BuiltinPreset::DevKanban), "devops_kanban" | "devopskanban" => Some(BuiltinPreset::DevopsKanban), "operator" => Some(BuiltinPreset::Operator), - "backstage_full" | "backstagefull" => Some(BuiltinPreset::BackstageFull), + "full" => Some(BuiltinPreset::Full), _ => None, } } @@ -267,10 +267,7 @@ mod tests { BuiltinPreset::from_name("operator"), Some(BuiltinPreset::Operator) ); - assert_eq!( - BuiltinPreset::from_name("backstage_full"), - Some(BuiltinPreset::BackstageFull) - ); + assert_eq!(BuiltinPreset::from_name("full"), Some(BuiltinPreset::Full)); assert_eq!(BuiltinPreset::from_name("unknown"), None); } @@ -282,9 +279,9 @@ mod tests { } #[test] - fn test_builtin_backstage_full() { - let collection = BuiltinPreset::BackstageFull.into_collection(); - assert_eq!(collection.name, "backstage_full"); + fn test_builtin_full() { + let collection = BuiltinPreset::Full.into_collection(); + assert_eq!(collection.name, "full"); assert_eq!( collection.types, vec!["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] diff --git a/src/lib.rs b/src/lib.rs index 32cc310..d77ec8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,6 @@ pub mod state; pub mod types; // Internal modules required by public modules -mod backstage; mod collections; mod issuetypes; mod llm; @@ -28,14 +27,27 @@ mod projects; mod services; mod startup; mod steps; +#[allow(dead_code)] +pub mod taxonomy; mod templates; pub mod version; +// Integration surface inventory (capability parity across surfaces) +pub mod integrations; + // MCP server bridge pub mod mcp; +// ACP agent bridge (Agent Client Protocol — editor-hosted sessions over stdio) +pub mod acp; + // Re-export env_vars for potential external use pub mod env_vars; // Relay hub and channel client pub mod relay; + +// Workflow export (ticket + issuetype -> Claude dynamic workflow .js). +// Declared here (in addition to the bin) so the REST layer, which compiles in +// both the lib and bin crates, can reach it. +pub mod workflow_gen; diff --git a/src/llm/detection.rs b/src/llm/detection.rs index 79cd1ef..42579c7 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -11,7 +11,7 @@ use crate::config::{DetectedTool, LlmProvider, LlmToolsConfig, ToolCapabilities} use super::tool_config::{load_all_tool_configs, ToolConfig}; /// Detect all available LLM CLI tools and build the config -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary, not reachable from lib.rs pub fn detect_all_tools() -> LlmToolsConfig { let tool_configs = load_all_tool_configs(); let mut detected = Vec::new(); diff --git a/src/main.rs b/src/main.rs index dc17b0a..8d6777a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; mod api; mod app; -mod backstage; mod collections; mod config; mod editors; @@ -18,9 +17,12 @@ mod projects; mod services; mod state; mod steps; +#[allow(dead_code)] +mod taxonomy; mod templates; mod types; +mod acp; mod agents; mod docs_gen; pub mod env_vars; @@ -33,6 +35,7 @@ mod setup; mod startup; mod ui; mod version; +mod workflow_gen; use agents::tmux::{SystemTmuxClient, TmuxClient, TmuxError}; use app::App; @@ -126,6 +129,10 @@ pub struct Cli { /// Start with web view enabled #[arg(short = 'w', long)] web: bool, + + /// Open the embedded web UI in a browser on launch + #[arg(long)] + ui: bool, } #[derive(Subcommand)] @@ -227,8 +234,25 @@ enum Commands { /// Port to listen on (default: 7008) #[arg(short, long)] port: Option, + + /// Open the web UI in browser after server starts + #[arg(long)] + open: bool, }, + /// Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, `JetBrains`, etc.). + /// + /// Reads line-delimited JSON-RPC from stdin and writes responses to stdout. + /// Log output goes to stderr. Intended to be spawned by an MCP-capable client. + Mcp, + + /// Run as an ACP agent over stdio (for use by Zed, `JetBrains`, Emacs `agent-shell`, etc.). + /// + /// Implements the Agent Client Protocol. Reads line-delimited JSON-RPC + /// from stdin and writes responses/notifications to stdout. Log output + /// goes to stderr. Intended to be spawned by an ACP-capable editor. + Acp, + /// Initialize operator workspace (non-interactive by default) Setup { /// Launch TUI setup wizard instead of non-interactive setup @@ -239,10 +263,6 @@ enum Commands { #[arg(short = 'C', long, default_value = "simple")] collection: String, - /// Enable backstage configuration - #[arg(long)] - backstage: bool, - /// Overwrite existing files #[arg(short, long)] force: bool, @@ -263,6 +283,25 @@ enum Commands { #[arg(long)] skip_llm_detection: bool, }, + + /// Convert between operator issuetypes and other orchestration formats + Workflow { + #[command(subcommand)] + action: WorkflowAction, + }, +} + +#[derive(Subcommand)] +enum WorkflowAction { + /// Export a ticket, rendered against its issuetype, to a Claude Code dynamic workflow (.js) + Export { + /// Ticket id (e.g. FEAT-1234) or path to a ticket markdown file + ticket: String, + + /// Output path (default: .workflow.js in the current directory; "-" for stdout) + #[arg(short, long)] + out: Option, + }, } #[tokio::main] @@ -278,6 +317,27 @@ async fn main() -> Result<()> { // Initialize logging (file-based for TUI, stderr for CLI) let logging_handle = logging::init_logging(&config, is_tui_mode, cli.debug)?; + // Inject the status-section provider into the REST layer. The section logic + // lives in `ui` (which `rest` can't depend on — see rest::dto::sections), so + // the binary registers it here, before any server starts. Covers all serving + // paths (TUI app, `operator rest`, embedded UI) since they share one process. + rest::dto::register_section_provider(std::sync::Arc::new(|config, registry| { + let issue_types = registry + .all_types() + .map(|it| ui::status_panel::IssueTypeInfo { + key: it.key.clone(), + name: it.name.clone(), + mode: if it.is_autonomous() { + "autonomous".to_string() + } else { + "paired".to_string() + }, + }) + .collect(); + let snapshot = ui::status_panel::StatusSnapshot::from_config(config, issue_types); + ui::status_panel::build_section_dtos(&snapshot) + })); + match cli.command { Some(Commands::Queue { all }) => { cmd_queue(&config, all).await?; @@ -329,13 +389,18 @@ async fn main() -> Result<()> { Some(Commands::Docs { output, only }) => { cmd_docs(&config, output, only)?; } - Some(Commands::Api { port }) => { - cmd_api(&config, port).await?; + Some(Commands::Api { port, open }) => { + cmd_api(&config, port, open).await?; + } + Some(Commands::Mcp) => { + cmd_mcp(&config).await?; + } + Some(Commands::Acp) => { + cmd_acp(&config).await?; } Some(Commands::Setup { interactive, collection, - backstage, force, working_dir, kanban_provider, @@ -346,7 +411,6 @@ async fn main() -> Result<()> { config, interactive, collection, - backstage, force, working_dir, kanban_provider, @@ -354,24 +418,32 @@ async fn main() -> Result<()> { skip_llm_detection, )?; } + Some(Commands::Workflow { action }) => { + cmd_workflow(&config, action)?; + } None => { // No subcommand = launch TUI dashboard #[allow(clippy::large_futures)] // TUI state is inherently large - run_tui(config, logging_handle.log_file_path, cli.web).await?; + run_tui(config, logging_handle.log_file_path, cli.web, cli.ui).await?; } } Ok(()) } -async fn run_tui(config: Config, log_file_path: Option, start_web: bool) -> Result<()> { +async fn run_tui( + config: Config, + log_file_path: Option, + start_web: bool, + open_ui: bool, +) -> Result<()> { // Install panic hook before any terminal operations // This ensures terminal is restored even on panic crate::ui::install_panic_hook(); // Note: tmux availability is now checked in the setup wizard (TmuxOnboarding step) // when the user selects tmux as their session wrapper - let mut app = App::new(config, start_web).await?; + let mut app = App::new(config, start_web, open_ui).await?; let result = app.run().await; // Print log file path on exit if logs were written @@ -669,6 +741,47 @@ async fn cmd_create( Ok(()) } +fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { + match action { + WorkflowAction::Export { ticket, out } => { + // Resolve the ticket (by id via the queue, or as a direct file path). + // Resolution is the only edge-specific step; the registry build and + // the export itself go through the same shared path as the REST API. + let resolved = { + let path = std::path::Path::new(&ticket); + if path.is_file() { + queue::Ticket::from_file(path)? + } else { + let queue = queue::Queue::new(config)?; + queue + .find_ticket(&ticket)? + .ok_or_else(|| anyhow::anyhow!("Ticket not found: {ticket}"))? + } + }; + + // Same registry loader the REST API (ApiState::new) uses. + let registry = startup::templates::load_registry(&config.tickets_path()); + let exported = workflow_gen::export_workflow_for_ticket(&resolved, ®istry, None)?; + + match out.as_deref() { + Some(p) if p == std::path::Path::new("-") => { + print!("{}", exported.contents); + } + Some(p) => { + std::fs::write(p, &exported.contents)?; + println!("Wrote workflow to {}", p.display()); + } + None => { + let default = PathBuf::from(&exported.suggested_filename); + std::fs::write(&default, &exported.contents)?; + println!("Wrote workflow to {}", default.display()); + } + } + } + } + Ok(()) +} + fn cmd_docs(_config: &Config, output: Option, only: Option) -> Result<()> { use docs_gen::{ cli, config, config_schema, issuetype, issuetype_json_schema, jira_api, metadata, openapi, @@ -775,7 +888,7 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Ok(()) } -async fn cmd_api(config: &Config, port: Option) -> Result<()> { +async fn cmd_api(config: &Config, port: Option, open: bool) -> Result<()> { let port = port.unwrap_or(config.rest_api.port); println!("Starting REST API server..."); @@ -790,18 +903,55 @@ async fn cmd_api(config: &Config, port: Option) -> Result<()> { println!(" GET /api/v1/collections List collections"); println!(); + if open { + let url = format!("http://localhost:{port}/"); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let opener = if cfg!(target_os = "macos") { + "open" + } else if cfg!(target_os = "windows") { + "cmd" + } else { + "xdg-open" + }; + if cfg!(target_os = "windows") { + let _ = std::process::Command::new(opener) + .args(["/C", "start", &url]) + .spawn(); + } else { + let _ = std::process::Command::new(opener).arg(&url).spawn(); + } + }); + } + let state = rest::ApiState::new(config.clone(), config.tickets_path()); rest::serve(state, port).await?; Ok(()) } +async fn cmd_mcp(config: &Config) -> Result<()> { + let state = rest::ApiState::new(config.clone(), config.tickets_path()); + tracing::info!("Starting MCP stdio server"); + mcp::stdio::run(state, tokio::io::stdin(), tokio::io::stdout()).await?; + tracing::info!("MCP stdio server stopped (stdin closed)"); + Ok(()) +} + +async fn cmd_acp(config: &Config) -> Result<()> { + tracing::info!("Starting ACP stdio agent"); + acp::run_stdio(config.clone()) + .await + .map_err(|e| anyhow::anyhow!("ACP transport error: {e:?}"))?; + tracing::info!("ACP agent stopped (stdin closed)"); + Ok(()) +} + #[allow(clippy::too_many_arguments)] fn cmd_setup( mut config: Config, interactive: bool, collection: String, - backstage: bool, force: bool, working_dir: Option, kanban_provider: Option, @@ -850,7 +1000,6 @@ fn cmd_setup( let options = SetupOptions { preset, - backstage_enabled: backstage, force, working_dir, kanban_provider, @@ -872,14 +1021,6 @@ fn cmd_setup( println!("Initializing operator workspace..."); println!(" Collection: {:?}", options.preset); - println!( - " Backstage: {}", - if options.backstage_enabled { - "enabled" - } else { - "disabled" - } - ); println!(" Force: {}", options.force); if let Some(ref dir) = options.working_dir { println!(" Working Dir: {}", dir.display()); diff --git a/src/mcp/client_configs.rs b/src/mcp/client_configs.rs new file mode 100644 index 0000000..41ce66d --- /dev/null +++ b/src/mcp/client_configs.rs @@ -0,0 +1,133 @@ +//! Generates copy-paste MCP client configuration snippets pointing at this +//! operator binary. +//! +//! Each `*_snippet(cwd)` returns a `serde_json::Value` shaped the way the +//! target client's config file expects. The dashboard writes one of these to +//! `/operator/mcp/.json` and opens it in the user's editor; +//! the user pastes the contents into their actual client config. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +/// Path to the currently-running operator binary. Falls back to bare +/// "operator" if `current_exe` is unavailable (e.g. in some test contexts). +pub fn current_exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +/// Shape used by Claude Code (`~/.claude.json`), Claude Desktop, and Cursor +/// (`~/.cursor/mcp.json`). All three accept the same `mcpServers` block. +fn mcp_servers_shape(cwd: &Path) -> Value { + json!({ + "mcpServers": { + "operator": { + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +pub fn claude_code_snippet(cwd: &Path) -> Value { + mcp_servers_shape(cwd) +} + +pub fn claude_desktop_snippet(cwd: &Path) -> Value { + mcp_servers_shape(cwd) +} + +/// Cursor's `~/.cursor/mcp.json` uses the same `mcpServers` shape as Claude. +pub fn cursor_snippet(cwd: &Path) -> Value { + mcp_servers_shape(cwd) +} + +/// VS Code (1.94+) per-workspace `.vscode/mcp.json` uses a `servers` block +/// with an explicit `type: "stdio"` discriminator. +pub fn vscode_snippet(cwd: &Path) -> Value { + json!({ + "servers": { + "operator": { + "type": "stdio", + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +/// Zed user settings under `context_servers`. +pub fn zed_snippet(cwd: &Path) -> Value { + json!({ + "context_servers": { + "operator": { + "command": { + "path": current_exe().to_string_lossy(), + "args": ["mcp"], + "env": {} + }, + "settings": { "cwd": cwd.to_string_lossy() } + } + } + }) +} + +/// Dispatch by client name. Returns `None` for unknown clients. +pub fn snippet_for(client: &str, cwd: &Path) -> Option { + match client { + "claude-code" => Some(claude_code_snippet(cwd)), + "claude-desktop" => Some(claude_desktop_snippet(cwd)), + "cursor" => Some(cursor_snippet(cwd)), + "vscode" => Some(vscode_snippet(cwd)), + "zed" => Some(zed_snippet(cwd)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claude_code_snippet_shape() { + let cfg = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["mcpServers"]["operator"]["args"][0], "mcp"); + assert_eq!(cfg["mcpServers"]["operator"]["cwd"], "/work"); + } + + #[test] + fn test_cursor_snippet_matches_claude_code() { + let cursor = cursor_snippet(&PathBuf::from("/work")); + let claude = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cursor, claude); + } + + #[test] + fn test_vscode_snippet_uses_servers_with_type() { + let cfg = vscode_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["servers"]["operator"]["type"], "stdio"); + assert_eq!(cfg["servers"]["operator"]["args"][0], "mcp"); + } + + #[test] + fn test_zed_snippet_uses_context_servers() { + let cfg = zed_snippet(&PathBuf::from("/work")); + assert!(cfg["context_servers"]["operator"]["command"]["path"].is_string()); + } + + #[test] + fn test_snippet_for_unknown_client_is_none() { + assert!(snippet_for("notepad++", &PathBuf::from("/w")).is_none()); + } + + #[test] + fn test_snippet_for_dispatches_correctly() { + let cwd = PathBuf::from("/w"); + assert!(snippet_for("claude-code", &cwd).is_some()); + assert!(snippet_for("claude-desktop", &cwd).is_some()); + assert!(snippet_for("cursor", &cwd).is_some()); + assert!(snippet_for("vscode", &cwd).is_some()); + assert!(snippet_for("zed", &cwd).is_some()); + } +} diff --git a/src/mcp/descriptor.rs b/src/mcp/descriptor.rs index 6d71d5b..aab1d3c 100644 --- a/src/mcp/descriptor.rs +++ b/src/mcp/descriptor.rs @@ -1,15 +1,34 @@ //! MCP descriptor endpoint for client discovery. //! -//! Returns metadata needed to build a VS Code MCP deep link, -//! including server name, transport URL, and version. +//! Returns metadata needed to register operator with an MCP-capable client. +//! Includes both the SSE transport URL (for network clients like the +//! vscode-extension) and, optionally, the stdio entrypoint command so +//! clients can spawn `operator mcp` as a subprocess instead. -use axum::extract::Host; +use axum::extract::{Host, State}; use axum::Json; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; +use crate::rest::state::ApiState; + +/// Stdio entrypoint advertised in the descriptor when +/// `[mcp].stdio_advertised = true`. Clients use this to spawn operator +/// as an MCP subprocess instead of (or alongside) the SSE transport. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StdioCommand { + /// Absolute path to the operator binary (the same binary serving this descriptor) + pub command: String, + /// Args to pass: typically `["mcp"]` + pub args: Vec, + /// Working directory the client should set when spawning. Defaults to the + /// operator process's current working directory. + pub cwd: String, +} + /// MCP server descriptor for client discovery #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -27,14 +46,19 @@ pub struct McpDescriptorResponse { /// URL of the OpenAPI spec for reference #[serde(skip_serializing_if = "Option::is_none")] pub openapi_url: Option, + /// Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`. + /// Clients may spawn this as a subprocess instead of using `transport_url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdio: Option, } /// MCP descriptor endpoint /// -/// Returns metadata for building a VS Code MCP deep link. -/// The transport URL is derived from the request Host header -/// so it reflects the actual running port. +/// Returns metadata for registering operator with an MCP-capable client. +/// The transport URL is derived from the request Host header so it reflects +/// the actual running port; the stdio entrypoint reflects this binary's path. #[utoipa::path( + operation_id = "mcp_descriptor", get, path = "/api/v1/mcp/descriptor", tag = "MCP", @@ -42,9 +66,30 @@ pub struct McpDescriptorResponse { (status = 200, description = "MCP server descriptor", body = McpDescriptorResponse) ) )] -pub async fn descriptor(Host(host): Host) -> Json { +pub async fn descriptor( + State(state): State, + Host(host): Host, +) -> Json { let base = format!("http://{host}"); + let stdio = if state.config.mcp.stdio_advertised { + let command = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "operator".to_string()); + let cwd = std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_default(); + Some(StdioCommand { + command, + args: vec!["mcp".to_string()], + cwd, + }) + } else { + None + }; + Json(McpDescriptorResponse { server_name: "operator".to_string(), server_id: "operator-mcp".to_string(), @@ -52,16 +97,26 @@ pub async fn descriptor(Host(host): Host) -> Json { transport_url: format!("{base}/api/v1/mcp/sse"), label: "Operator MCP Server".to_string(), openapi_url: Some(format!("{base}/api-docs/openapi.json")), + stdio, }) } #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn state_with_stdio(advertised: bool) -> ApiState { + let mut config = Config::default(); + config.mcp.stdio_advertised = advertised; + ApiState::new(config, PathBuf::from("/tmp/test")) + } #[tokio::test] async fn test_descriptor_response() { - let resp = descriptor(Host("localhost:7008".to_string())).await; + let state = state_with_stdio(true); + let resp = descriptor(State(state), Host("localhost:7008".to_string())).await; assert_eq!(resp.server_name, "operator"); assert_eq!(resp.server_id, "operator-mcp"); @@ -76,7 +131,8 @@ mod tests { #[tokio::test] async fn test_descriptor_custom_port() { - let resp = descriptor(Host("localhost:9999".to_string())).await; + let state = state_with_stdio(true); + let resp = descriptor(State(state), Host("localhost:9999".to_string())).await; assert_eq!(resp.transport_url, "http://localhost:9999/api/v1/mcp/sse"); assert_eq!( @@ -84,4 +140,24 @@ mod tests { Some("http://localhost:9999/api-docs/openapi.json".to_string()) ); } + + #[tokio::test] + async fn test_descriptor_stdio_present_when_advertised() { + let state = state_with_stdio(true); + let resp = descriptor(State(state), Host("localhost:7008".to_string())).await; + + let stdio = resp.stdio.as_ref().expect("stdio should be present"); + assert_eq!(stdio.args, vec!["mcp".to_string()]); + assert!( + !stdio.command.is_empty(), + "command path should be populated from current_exe" + ); + } + + #[tokio::test] + async fn test_descriptor_stdio_absent_when_disabled() { + let state = state_with_stdio(false); + let resp = descriptor(State(state), Host("localhost:7008".to_string())).await; + assert!(resp.stdio.is_none()); + } } diff --git a/src/mcp/handler.rs b/src/mcp/handler.rs new file mode 100644 index 0000000..4dc7a59 --- /dev/null +++ b/src/mcp/handler.rs @@ -0,0 +1,316 @@ +//! Transport-agnostic JSON-RPC handler for MCP. +//! +//! Both the HTTP/SSE transport (`transport.rs`) and the stdio transport +//! (`stdio.rs`) dispatch through `handle_jsonrpc`. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::mcp::tools; +use crate::rest::state::ApiState; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + #[allow(dead_code)] + pub jsonrpc: String, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, +} + +pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { + let id = request.id.clone().unwrap_or(Value::Null); + + match request.method.as_str() { + "initialize" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "resources": { "subscribe": false, "listChanged": false } + }, + "serverInfo": { + "name": "operator", + "version": env!("CARGO_PKG_VERSION") + } + })), + error: None, + }, + + "notifications/initialized" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({})), + error: None, + }, + + "tools/list" => { + let tool_defs = tools::all_tool_definitions(); + let tools_json: Vec = tool_defs + .into_iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema + }) + }) + .collect(); + + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "tools": tools_json })), + error: None, + } + } + + "tools/call" => { + let tool_name = request + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = request + .params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + + match tools::execute_tool(tool_name, arguments, state).await { + Ok(result) => { + let text = serde_json::to_string_pretty(&result).unwrap_or_default(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [{ + "type": "text", + "text": text + }] + })), + error: None, + } + } + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32000, + message: e, + }), + }, + } + } + + "resources/list" => { + let resources = crate::mcp::resources::list_resources(state) + .await + .unwrap_or_default(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "resources": resources })), + error: None, + } + } + + "resources/read" => { + let uri = request + .params + .get("uri") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match crate::mcp::resources::read_resource(uri, state).await { + Ok(contents) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "contents": [{ + "uri": uri, + "mimeType": "text/markdown", + "text": contents + }] + })), + error: None, + }, + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32000, + message: e, + }), + }, + } + } + + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + }), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn test_state() -> ApiState { + let config = Config::default(); + ApiState::new(config, PathBuf::from("/tmp/test")) + } + + #[tokio::test] + async fn test_handle_initialize() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(1)), + method: "initialize".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.id, json!(1)); + assert!(response.error.is_none()); + + let result = response.result.unwrap(); + assert_eq!(result["protocolVersion"], "2024-11-05"); + assert!(result["capabilities"]["tools"].is_object()); + assert!(result["capabilities"]["resources"].is_object()); + assert_eq!(result["serverInfo"]["name"], "operator"); + } + + #[tokio::test] + async fn test_handle_tools_list() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(2)), + method: "tools/list".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_none()); + let result = response.result.unwrap(); + let tools_arr = result["tools"].as_array().unwrap(); + assert_eq!(tools_arr.len(), 18); + + let first = &tools_arr[0]; + assert!(first.get("name").is_some()); + assert!(first.get("description").is_some()); + assert!(first.get("inputSchema").is_some()); + } + + #[tokio::test] + async fn test_handle_tools_call_health() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(3)), + method: "tools/call".to_string(), + params: json!({ + "name": "operator_health", + "arguments": {} + }), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_none()); + let result = response.result.unwrap(); + let content = result["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + + let text = content[0]["text"].as_str().unwrap(); + let health: Value = serde_json::from_str(text).unwrap(); + assert_eq!(health["status"], "ok"); + } + + #[tokio::test] + async fn test_handle_tools_call_unknown() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(4)), + method: "tools/call".to_string(), + params: json!({ + "name": "nonexistent", + "arguments": {} + }), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_some()); + assert!(response.error.unwrap().message.contains("Unknown tool")); + } + + #[tokio::test] + async fn test_handle_unknown_method() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(5)), + method: "unknown/method".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_some()); + let err = response.error.unwrap(); + assert_eq!(err.code, -32601); + assert!(err.message.contains("Method not found")); + } + + #[tokio::test] + async fn test_handle_notifications_initialized() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(6)), + method: "notifications/initialized".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_none()); + assert!(response.result.is_some()); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 4a8e5cc..342f201 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -4,6 +4,11 @@ //! read-only MCP tools. Includes a descriptor endpoint for client discovery, //! tool definitions, and an SSE transport for JSON-RPC communication. +pub mod client_configs; pub mod descriptor; +pub mod handler; +pub mod resources; +pub mod stdio; +pub mod tickets; pub mod tools; pub mod transport; diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs new file mode 100644 index 0000000..5c8f7b0 --- /dev/null +++ b/src/mcp/resources.rs @@ -0,0 +1,103 @@ +//! MCP resources — exposes tickets as URI-addressable resources. +//! +//! Each ticket is reachable at `operator://tickets/{status}/{id}` where status +//! is one of `queue`, `in-progress`, `completed`. Resource reads return the +//! raw markdown body. + +use serde_json::{json, Value}; + +use crate::queue::Queue; +use crate::rest::state::ApiState; + +pub async fn list_resources(state: &ApiState) -> Result, String> { + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let mut all = Vec::new(); + for (status, list) in [ + ("queue", queue.list_queue()), + ("in-progress", queue.list_in_progress()), + ("completed", queue.list_completed()), + ] { + for t in list.map_err(|e| e.to_string())? { + all.push(json!({ + "uri": format!("operator://tickets/{status}/{}", t.id), + "name": t.filename, + "mimeType": "text/markdown", + "description": t.summary, + })); + } + } + Ok(all) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn read_resource(uri: &str, state: &ApiState) -> Result { + let prefix = "operator://tickets/"; + let rest = uri + .strip_prefix(prefix) + .ok_or_else(|| format!("Unknown URI scheme: {uri}"))?; + let (status, id) = rest + .split_once('/') + .ok_or_else(|| format!("Malformed URI: {uri}"))?; + + let config = (*state.config).clone(); + let status = status.to_string(); + let id = id.to_string(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + let ticket = list + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("Ticket {id} not found"))?; + std::fs::read_to_string(&ticket.filepath).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.keep(); + let mut config = Config::default(); + config.paths.tickets = path.to_string_lossy().into_owned(); + ApiState::new(config, path) + } + + #[tokio::test] + async fn test_list_resources_empty() { + let state = test_state(); + let resources = list_resources(&state).await.unwrap(); + assert!(resources.is_empty()); + } + + #[tokio::test] + async fn test_read_resource_unknown_scheme() { + let state = test_state(); + let err = read_resource("file:///tmp/x", &state).await.unwrap_err(); + assert!(err.contains("Unknown URI scheme")); + } + + #[tokio::test] + async fn test_read_resource_malformed() { + let state = test_state(); + let err = read_resource("operator://tickets/queue", &state) + .await + .unwrap_err(); + assert!(err.contains("Malformed URI")); + } +} diff --git a/src/mcp/stdio.rs b/src/mcp/stdio.rs new file mode 100644 index 0000000..b80d4ce --- /dev/null +++ b/src/mcp/stdio.rs @@ -0,0 +1,93 @@ +//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout. +//! +//! Each line on stdin is one JSON-RPC request. Each response is one JSON +//! object written to stdout terminated by `\n`. Logs and diagnostics go to +//! stderr (via `tracing`). This is the transport MCP clients use when they +//! spawn `operator mcp` as a subprocess. + +use std::io; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +use crate::rest::state::ApiState; + +/// Run the stdio MCP loop until stdin closes. +/// +/// `reader`/`writer` are generic for testability; production callers pass +/// `tokio::io::stdin()` and `tokio::io::stdout()`. +pub async fn run(state: ApiState, reader: R, mut writer: W) -> io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut lines = BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let request: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, line = %line, "Malformed JSON-RPC request"); + continue; + } + }; + let response = handle_jsonrpc(&request, &state).await; + let json = serde_json::to_string(&response).unwrap_or_else(|_| { + r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"# + .to_string() + }); + writer.write_all(json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + // Leak the tempdir so the path survives for the duration of the test; + // ApiState may keep references to files inside it. + let path = temp.keep(); + ApiState::new(Config::default(), path) + } + + #[tokio::test] + async fn test_stdio_roundtrip_initialize() { + let state = test_state(); + let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + + let response_str = std::str::from_utf8(&output).unwrap(); + let response: serde_json::Value = serde_json::from_str(response_str.trim()).unwrap(); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["serverInfo"]["name"], "operator"); + } + + #[tokio::test] + async fn test_stdio_ignores_blank_lines() { + let state = test_state(); + let input = b"\n\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + assert!(output.is_empty()); + } + + #[tokio::test] + async fn test_stdio_malformed_line_is_skipped() { + let state = test_state(); + let input = + b"not json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + let response_str = std::str::from_utf8(&output).unwrap(); + assert_eq!(response_str.matches('\n').count(), 1); + } +} diff --git a/src/mcp/tickets.rs b/src/mcp/tickets.rs new file mode 100644 index 0000000..2fe7984 --- /dev/null +++ b/src/mcp/tickets.rs @@ -0,0 +1,313 @@ +//! Ticket-queue MCP tools. +//! +//! Reads/writes via `crate::queue::Queue` which uses blocking `std::fs`, +//! so all calls are wrapped in `tokio::task::spawn_blocking`. + +use serde_json::{json, Value}; + +use crate::queue::{Queue, Ticket}; +use crate::rest::state::ApiState; + +fn ticket_to_json(t: &Ticket) -> Value { + json!({ + "id": t.id, + "filename": t.filename, + "project": t.project, + "ticket_type": t.ticket_type, + "summary": t.summary, + "priority": t.priority, + "status": t.status, + "branch": t.branch, + "external_id": t.external_id, + "external_url": t.external_url, + "external_provider": t.external_provider, + }) +} + +pub async fn list_tickets(args: Value, state: &ApiState) -> Result { + let status = args + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("queue") + .to_string(); + let config = (*state.config).clone(); + let tickets = tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + match status.as_str() { + "queue" => queue.list_queue().map_err(|e| e.to_string()), + "in-progress" => queue.list_in_progress().map_err(|e| e.to_string()), + "completed" => queue.list_completed().map_err(|e| e.to_string()), + other => Err(format!("Unknown ticket status: {other}")), + } + }) + .await + .map_err(|e| e.to_string())??; + + let json_tickets: Vec = tickets.iter().map(ticket_to_json).collect(); + Ok(json!({ "tickets": json_tickets, "count": json_tickets.len() })) +} + +async fn find_ticket(state: &ApiState, id: &str, in_status: &str) -> Result { + let id = id.to_string(); + let in_status = in_status.to_string(); + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match in_status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + list.into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("Ticket {id} not found in {in_status}")) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn claim_ticket(args: Value, state: &ApiState) -> Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "queue").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.claim_ticket(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "in-progress" })) +} + +pub async fn complete_ticket(args: Value, state: &ApiState) -> Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "in-progress").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.complete_ticket(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "completed" })) +} + +pub async fn return_to_queue(args: Value, state: &ApiState) -> Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "in-progress").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.return_to_queue(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "queue" })) +} + +pub async fn create_ticket(args: Value, state: &ApiState) -> Result { + use crate::queue::creator::TicketCreator; + use crate::templates::TemplateType; + use std::collections::HashMap; + + let template_str = args + .get("template") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: template")?; + let template_type = TemplateType::from_key(template_str) + .ok_or_else(|| format!("Unknown template type: {template_str}"))?; + + let mut values: HashMap = HashMap::new(); + if let Some(obj) = args.get("values").and_then(|v| v.as_object()) { + for (k, v) in obj { + if let Some(s) = v.as_str() { + values.insert(k.clone(), s.to_string()); + } + } + } + + let config = (*state.config).clone(); + let path = tokio::task::spawn_blocking(move || -> Result { + let creator = TicketCreator::new(&config); + creator + .create_ticket_headless(template_type, &values) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(json!({ + "path": path.to_string_lossy(), + "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn test_state_with_config(mut config: Config) -> (ApiState, PathBuf) { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.keep(); + // Point the config's tickets path at the tempdir so Queue::new resolves there. + config.paths.tickets = path.to_string_lossy().into_owned(); + let state = ApiState::new(config, path.clone()); + (state, path) + } + + fn test_state() -> ApiState { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + test_state_with_config(config).0 + } + + /// Write a fake ticket file into `/queue/` and create the + /// `in-progress` and `completed` sibling dirs (`Queue::{claim,complete,return}` + /// use `fs::rename` which requires the target dir to exist). Returns the id. + fn seed_queue_ticket(tickets_path: &std::path::Path, id: &str) -> String { + let queue_dir = tickets_path.join("queue"); + std::fs::create_dir_all(&queue_dir).unwrap(); + std::fs::create_dir_all(tickets_path.join("in-progress")).unwrap(); + std::fs::create_dir_all(tickets_path.join("completed")).unwrap(); + let filename = format!("20260516-1200-FEAT-demo-test-{id}.md"); + let body = format!( + "---\nid: {id}\npriority: P2-medium\nstatus: queued\n---\n\n# Task: Test {id}\n" + ); + std::fs::write(queue_dir.join(&filename), body).unwrap(); + id.to_string() + } + + #[tokio::test] + async fn test_list_tickets_empty_queue() { + let state = test_state(); + let result = list_tickets(json!({}), &state).await.unwrap(); + assert_eq!(result["count"], 0); + } + + #[tokio::test] + async fn test_list_tickets_unknown_status_errors() { + let state = test_state(); + let err = list_tickets(json!({ "status": "bogus" }), &state) + .await + .unwrap_err(); + assert!(err.contains("Unknown ticket status")); + } + + #[tokio::test] + async fn test_claim_ticket_moves_file() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + let id = seed_queue_ticket(&tickets_path, "FEAT-1234"); + + let result = claim_ticket(json!({ "id": id }), &state).await.unwrap(); + assert_eq!(result["moved_to"], "in-progress"); + + let in_progress = tickets_path.join("in-progress"); + let entries: Vec<_> = std::fs::read_dir(&in_progress).unwrap().collect(); + assert_eq!(entries.len(), 1); + + let queue_dir = tickets_path.join("queue"); + let entries: Vec<_> = std::fs::read_dir(&queue_dir).unwrap().collect(); + assert_eq!(entries.len(), 0); + } + + #[tokio::test] + async fn test_complete_then_return_ticket() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + let id = seed_queue_ticket(&tickets_path, "FEAT-5678"); + + claim_ticket(json!({ "id": &id }), &state).await.unwrap(); + let res = complete_ticket(json!({ "id": &id }), &state).await.unwrap(); + assert_eq!(res["moved_to"], "completed"); + let completed = tickets_path.join("completed"); + assert_eq!(std::fs::read_dir(&completed).unwrap().count(), 1); + } + + #[tokio::test] + async fn test_return_to_queue_moves_back() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + let id = seed_queue_ticket(&tickets_path, "FEAT-9999"); + + claim_ticket(json!({ "id": &id }), &state).await.unwrap(); + let res = return_to_queue(json!({ "id": &id }), &state).await.unwrap(); + assert_eq!(res["moved_to"], "queue"); + let queue_dir = tickets_path.join("queue"); + assert_eq!(std::fs::read_dir(&queue_dir).unwrap().count(), 1); + } + + #[tokio::test] + async fn test_create_ticket_writes_file_to_queue() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + + let result = create_ticket( + json!({ + "template": "FEAT", + "values": { "project": "demo", "summary": "from mcp test" } + }), + &state, + ) + .await + .unwrap(); + let filename = result["filename"].as_str().unwrap(); + assert!( + filename.contains("FEAT-demo"), + "filename should contain FEAT-demo, got: {filename}" + ); + + let queue_dir = tickets_path.join("queue"); + let entries: Vec<_> = std::fs::read_dir(&queue_dir).unwrap().collect(); + assert_eq!(entries.len(), 1); + } + + #[tokio::test] + async fn test_create_ticket_unknown_template_errors() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, _) = test_state_with_config(config); + + let err = create_ticket(json!({ "template": "nope" }), &state) + .await + .unwrap_err(); + assert!(err.contains("Unknown template type")); + } + + #[tokio::test] + async fn test_claim_ticket_gate_blocks_when_disabled() { + // Default config has expose_ticket_write_tools = false. + let config = Config::default(); + let (state, tickets_path) = test_state_with_config(config); + seed_queue_ticket(&tickets_path, "FEAT-0001"); + + let err = crate::mcp::tools::execute_tool( + "operator_claim_ticket", + json!({ "id": "FEAT-0001" }), + &state, + ) + .await + .unwrap_err(); + assert!(err.contains("disabled in config")); + } +} diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index e81ffe2..633319b 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1,12 +1,15 @@ //! MCP tool definitions and execution. //! -//! Defines read-only tools that wrap existing REST API route handlers. +//! Defines read-only and write tools that wrap existing REST API route handlers. //! Each tool calls the handler directly (no internal HTTP round-trip). +//! Write tools are gated behind `[mcp].expose_ticket_write_tools`. use axum::extract::{Path, State}; +use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::rest::dto::{LaunchTicketRequest, RejectReviewRequest}; use crate::rest::routes; use crate::rest::state::ApiState; @@ -95,9 +98,170 @@ pub fn all_tool_definitions() -> Vec { "required": [] }), }, + McpToolDefinition { + name: "operator_list_tickets".to_string(), + description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["queue", "in-progress", "completed"], + "default": "queue", + "description": "Which directory to list (defaults to queue)" + } + }, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_claim_ticket".to_string(), + description: "Move a ticket from queue to in-progress. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Ticket id (e.g. FEAT-1234)" } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_complete_ticket".to_string(), + description: "Move a ticket from in-progress to completed. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Ticket id" } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_return_to_queue".to_string(), + description: "Move a ticket from in-progress back to queue (un-claim). Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Ticket id" } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_create_ticket".to_string(), + description: "Create a new ticket from a template (feature, fix, spike, investigation, task) and write it to the queue. Returns the filename. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Template type key (feature, fix, spike, investigation, task)" + }, + "values": { + "type": "object", + "description": "Handlebars values for the template (project, summary, id, etc.)" + } + }, + "required": ["template"] + }), + }, + McpToolDefinition { + name: "operator_launch_ticket".to_string(), + description: "Launch/start a ticket by claiming it and preparing an agent. Returns agent details and launch command. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Ticket ID to launch (e.g. FEAT-1234)" + }, + "model": { + "type": "string", + "description": "Model to use (default: sonnet)", + "default": "sonnet" + }, + "wrapper": { + "type": "string", + "description": "Session wrapper type: vscode, tmux, cmux, terminal (default: terminal)", + "default": "terminal" + } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_pause_queue".to_string(), + description: "Pause queue processing, stopping automatic ticket launches. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_resume_queue".to_string(), + description: "Resume queue processing, re-enabling automatic ticket launches. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_sync_kanban".to_string(), + description: "Sync kanban collections from external providers (Jira, Linear, etc.) and create local tickets. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_approve_agent".to_string(), + description: "Approve an agent's pending review, signaling it to continue. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Agent ID to approve" + } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_reject_agent".to_string(), + description: "Reject an agent's pending review with a reason. The agent will re-do the work based on the feedback. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Agent ID to reject" + }, + "reason": { + "type": "string", + "description": "Reason for rejection (feedback for the agent)" + } + }, + "required": ["id", "reason"] + }), + }, ] } +fn require_write_tools(state: &ApiState) -> Result<(), String> { + if state.config.mcp.expose_ticket_write_tools { + Ok(()) + } else { + Err( + "Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)" + .to_string(), + ) + } +} + /// Execute an MCP tool by name with the given arguments pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { match name { @@ -146,6 +310,118 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result crate::mcp::tickets::list_tickets(args, state).await, + "operator_claim_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::claim_ticket(args, state).await + } + "operator_complete_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::complete_ticket(args, state).await + } + "operator_return_to_queue" => { + require_write_tools(state)?; + crate::mcp::tickets::return_to_queue(args, state).await + } + "operator_create_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::create_ticket(args, state).await + } + "operator_launch_ticket" => { + require_write_tools(state)?; + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: id".to_string())?; + let model = args + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("sonnet"); + let wrapper = args + .get("wrapper") + .and_then(|v| v.as_str()) + .unwrap_or("terminal"); + let request = LaunchTicketRequest { + delegator: None, + provider: None, + model: Some(model.to_string()), + yolo_mode: false, + wrapper: Some(wrapper.to_string()), + retry_reason: None, + resume_session_id: None, + }; + let result = routes::launch::launch_ticket( + State(state.clone()), + Path(id.to_string()), + Json(request), + ) + .await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_pause_queue" => { + require_write_tools(state)?; + let result = routes::queue::pause(State(state.clone())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_resume_queue" => { + require_write_tools(state)?; + let result = routes::queue::resume(State(state.clone())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_sync_kanban" => { + require_write_tools(state)?; + let result = routes::queue::sync(State(state.clone())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_approve_agent" => { + require_write_tools(state)?; + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: id".to_string())?; + let result = + routes::agents::approve_review(State(state.clone()), Path(id.to_string())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_reject_agent" => { + require_write_tools(state)?; + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: id".to_string())?; + let reason = args + .get("reason") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: reason".to_string())?; + let request = RejectReviewRequest { + reason: reason.to_string(), + }; + let result = routes::agents::reject_review( + State(state.clone()), + Path(id.to_string()), + Json(request), + ) + .await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } _ => Err(format!("Unknown tool: {name}")), } } @@ -159,7 +435,7 @@ mod tests { #[test] fn test_all_tool_definitions_count() { let tools = all_tool_definitions(); - assert_eq!(tools.len(), 7); + assert_eq!(tools.len(), 18); } #[test] @@ -174,6 +450,13 @@ mod tests { assert!(names.contains(&"operator_list_collections")); assert!(names.contains(&"operator_get_collection")); assert!(names.contains(&"operator_list_skills")); + assert!(names.contains(&"operator_list_tickets")); + assert!(names.contains(&"operator_launch_ticket")); + assert!(names.contains(&"operator_pause_queue")); + assert!(names.contains(&"operator_resume_queue")); + assert!(names.contains(&"operator_sync_kanban")); + assert!(names.contains(&"operator_approve_agent")); + assert!(names.contains(&"operator_reject_agent")); } #[test] @@ -269,4 +552,84 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("Unknown tool")); } + + // --- Write tool gate tests (write tools disabled by default) --- + + #[tokio::test] + async fn test_execute_launch_ticket_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_launch_ticket", json!({"id": "FEAT-1"}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_pause_queue_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_pause_queue", json!({}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_resume_queue_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_resume_queue", json!({}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_sync_kanban_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_sync_kanban", json!({}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_approve_agent_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_approve_agent", json!({"id": "agent-1"}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_reject_agent_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool( + "operator_reject_agent", + json!({"id": "agent-1", "reason": "bad"}), + &state, + ) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_launch_ticket_requires_id() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_launch_ticket", json!({}), &state).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Missing required parameter: id")); + } } diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index 50614b0..b9f5bb1 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -12,44 +12,15 @@ use axum::extract::{Host, Query, State}; use axum::response::sse::{Event, Sse}; use axum::response::IntoResponse; use axum::Json; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde::Deserialize; +use serde_json::json; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::StreamExt as _; -use crate::mcp::tools; +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; use crate::rest::state::ApiState; -/// JSON-RPC request structure -#[derive(Debug, Deserialize)] -pub struct JsonRpcRequest { - #[allow(dead_code)] - jsonrpc: String, - id: Option, - method: String, - #[serde(default)] - params: Value, -} - -/// JSON-RPC response structure -#[derive(Debug, Serialize)] -struct JsonRpcResponse { - jsonrpc: String, - id: Value, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -/// JSON-RPC error -#[derive(Debug, Serialize)] -struct JsonRpcError { - code: i64, - message: String, -} - /// Query parameters for the message endpoint #[derive(Debug, Deserialize)] pub struct MessageQuery { @@ -92,7 +63,7 @@ pub async fn sse_handler( // Clean up session after 1 hour or when stream ends tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(3600)).await; + tokio::time::sleep(Duration::from_hours(1)).await; sessions_cleanup.lock().await.remove(&session_id_cleanup); }); @@ -131,241 +102,3 @@ pub async fn message_handler( (axum::http::StatusCode::ACCEPTED, Json(json!({}))) } - -/// Handle a JSON-RPC request and return the response -async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { - let id = request.id.clone().unwrap_or(Value::Null); - - match request.method.as_str() { - "initialize" => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "operator", - "version": env!("CARGO_PKG_VERSION") - } - })), - error: None, - }, - - "notifications/initialized" => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({})), - error: None, - }, - - "tools/list" => { - let tool_defs = tools::all_tool_definitions(); - let tools_json: Vec = tool_defs - .into_iter() - .map(|t| { - json!({ - "name": t.name, - "description": t.description, - "inputSchema": t.input_schema - }) - }) - .collect(); - - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ "tools": tools_json })), - error: None, - } - } - - "tools/call" => { - let tool_name = request - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let arguments = request - .params - .get("arguments") - .cloned() - .unwrap_or_else(|| json!({})); - - match tools::execute_tool(tool_name, arguments, state).await { - Ok(result) => { - let text = serde_json::to_string_pretty(&result).unwrap_or_default(); - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [{ - "type": "text", - "text": text - }] - })), - error: None, - } - } - Err(e) => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { - code: -32000, - message: e, - }), - }, - } - } - - _ => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { - code: -32601, - message: format!("Method not found: {}", request.method), - }), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use std::path::PathBuf; - - fn test_state() -> ApiState { - let config = Config::default(); - ApiState::new(config, PathBuf::from("/tmp/test")) - } - - #[tokio::test] - async fn test_handle_initialize() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(1)), - method: "initialize".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert_eq!(response.jsonrpc, "2.0"); - assert_eq!(response.id, json!(1)); - assert!(response.error.is_none()); - - let result = response.result.unwrap(); - assert_eq!(result["protocolVersion"], "2024-11-05"); - assert!(result["capabilities"]["tools"].is_object()); - assert_eq!(result["serverInfo"]["name"], "operator"); - } - - #[tokio::test] - async fn test_handle_tools_list() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(2)), - method: "tools/list".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_none()); - let result = response.result.unwrap(); - let tools_arr = result["tools"].as_array().unwrap(); - assert_eq!(tools_arr.len(), 7); - - // Verify first tool has expected shape - let first = &tools_arr[0]; - assert!(first.get("name").is_some()); - assert!(first.get("description").is_some()); - assert!(first.get("inputSchema").is_some()); - } - - #[tokio::test] - async fn test_handle_tools_call_health() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(3)), - method: "tools/call".to_string(), - params: json!({ - "name": "operator_health", - "arguments": {} - }), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_none()); - let result = response.result.unwrap(); - let content = result["content"].as_array().unwrap(); - assert_eq!(content.len(), 1); - assert_eq!(content[0]["type"], "text"); - - // Parse the text content to verify it's valid health JSON - let text = content[0]["text"].as_str().unwrap(); - let health: Value = serde_json::from_str(text).unwrap(); - assert_eq!(health["status"], "ok"); - } - - #[tokio::test] - async fn test_handle_tools_call_unknown() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(4)), - method: "tools/call".to_string(), - params: json!({ - "name": "nonexistent", - "arguments": {} - }), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_some()); - assert!(response.error.unwrap().message.contains("Unknown tool")); - } - - #[tokio::test] - async fn test_handle_unknown_method() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(5)), - method: "unknown/method".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_some()); - let err = response.error.unwrap(); - assert_eq!(err.code, -32601); - assert!(err.message.contains("Method not found")); - } - - #[tokio::test] - async fn test_handle_notifications_initialized() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(6)), - method: "notifications/initialized".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_none()); - assert!(response.result.is_some()); - } -} diff --git a/src/notifications/os_integration.rs b/src/notifications/os_integration.rs index e86b408..1f52bcb 100644 --- a/src/notifications/os_integration.rs +++ b/src/notifications/os_integration.rs @@ -12,13 +12,14 @@ use crate::config::OsNotificationConfig; /// Sends notifications using the platform's native notification system: /// - macOS: Uses `mac-notification-sys` /// - Linux: Uses `notify-rust` (freedesktop notifications) -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary, not reachable from lib.rs pub struct OsIntegration { enabled: bool, sound: bool, subscribed_events: Vec, } +#[allow(dead_code)] impl OsIntegration { /// Create a new OS integration from config. pub fn new(config: &OsNotificationConfig) -> Self { @@ -30,7 +31,6 @@ impl OsIntegration { } /// Create a disabled OS integration. - #[allow(dead_code)] pub fn disabled() -> Self { Self { enabled: false, diff --git a/src/notifications/service.rs b/src/notifications/service.rs index f7e3c80..39778f1 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -14,14 +14,15 @@ use crate::config::Config; /// /// Receives events and dispatches them to all enabled integrations /// that handle the given event type. +#[allow(dead_code)] // Used via binary, not reachable from lib.rs pub struct NotificationService { integrations: Vec>, enabled: bool, } +#[allow(dead_code)] impl NotificationService { /// Create a new notification service from config. - #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub fn from_config(config: &Config) -> Result { let mut integrations: Vec> = Vec::new(); @@ -64,7 +65,6 @@ impl NotificationService { } /// Create a disabled notification service (for testing). - #[allow(dead_code)] pub fn disabled() -> Self { Self { integrations: Vec::new(), @@ -73,13 +73,11 @@ impl NotificationService { } /// Check if notifications are globally enabled. - #[allow(dead_code)] pub fn is_enabled(&self) -> bool { self.enabled } /// Get the number of registered integrations. - #[allow(dead_code)] pub fn integration_count(&self) -> usize { self.integrations.len() } @@ -88,7 +86,6 @@ impl NotificationService { /// /// This is fire-and-forget - each integration is spawned as a separate task /// and errors are logged but not propagated. - #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub async fn notify(&self, event: NotificationEvent) { if !self.enabled { return; @@ -118,7 +115,6 @@ impl NotificationService { /// /// This is useful for contexts where async is not available. /// Only dispatches to OS integration (webhooks require async). - #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub fn notify_sync(&self, event: NotificationEvent) { if !self.enabled { return; diff --git a/src/queue/creator.rs b/src/queue/creator.rs index 5996549..6a625ba 100644 --- a/src/queue/creator.rs +++ b/src/queue/creator.rs @@ -26,22 +26,17 @@ impl TicketCreator { } } - /// Create a new ticket from template with pre-filled values and open in editor. - /// - /// Returns the path to the created ticket file. - /// `editor_cmd` is the resolved editor command (e.g. from `EditorConfig::file_editor()`). - pub fn create_ticket_with_values( + /// Create a new ticket from template with pre-filled values, without + /// launching an editor. Used by MCP and other headless callers. + pub fn create_ticket_headless( &self, template_type: TemplateType, values: &HashMap, - editor_cmd: &str, ) -> Result { - // Generate filename with timestamp let now = Utc::now(); let timestamp = now.format("%Y%m%d-%H%M").to_string(); let type_str = template_type.as_str(); - // Get project from values or use "global" let project = values .get("project") .filter(|p| !p.is_empty()) @@ -51,19 +46,27 @@ impl TicketCreator { let filename = format!("{timestamp}-{type_str}-{project}-new-ticket.md"); let filepath = self.queue_path.join(&filename); - // Render template with handlebar values let template = template_type.template_content(); let content = render_template(template, values)?; - // Ensure queue directory exists fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; - - // Write to file fs::write(&filepath, &content).context("Failed to write ticket file")?; - // Open in editor - self.open_in_editor(&filepath, editor_cmd)?; + Ok(filepath) + } + /// Create a new ticket from template with pre-filled values and open in editor. + /// + /// Returns the path to the created ticket file. + /// `editor_cmd` is the resolved editor command (e.g. from `EditorConfig::file_editor()`). + pub fn create_ticket_with_values( + &self, + template_type: TemplateType, + values: &HashMap, + editor_cmd: &str, + ) -> Result { + let filepath = self.create_ticket_headless(template_type, values)?; + self.open_in_editor(&filepath, editor_cmd)?; Ok(filepath) } diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index a88a6ed..688d642 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -152,6 +152,61 @@ pub struct ActiveAgentsResponse { pub count: usize, } +// ============================================================================= +// Agent Detail DTOs +// ============================================================================= + +/// Full details for a single agent +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AgentDetailResponse { + /// Agent ID (UUID) + pub id: String, + /// Associated ticket ID (e.g., "FEAT-042") + pub ticket_id: String, + /// Ticket type: FEAT, FIX, INV, SPIKE + pub ticket_type: String, + /// Project being worked on + pub project: String, + /// Agent status: running, `awaiting_input`, completing, orphaned + pub status: String, + /// When the agent started (ISO 8601) + pub started_at: String, + /// Last activity timestamp (ISO 8601) + pub last_activity: String, + /// Current workflow step + #[serde(skip_serializing_if = "Option::is_none")] + pub current_step: Option, + /// LLM tool used (e.g., "claude", "gemini", "codex") + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_tool: Option, + /// LLM model alias (e.g., "opus", "sonnet", "gpt-4o") + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_model: Option, + /// Launch mode: "default", "yolo", "docker", "docker-yolo" + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_mode: Option, + /// PR URL if created during "pr" step + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + /// Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_status: Option, + /// Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrapper: Option, + /// Review state for `awaiting_input` agents + #[serde(skip_serializing_if = "Option::is_none")] + pub review_state: Option, + /// Completed steps for this ticket + pub completed_steps: Vec, + /// Path to the git worktree for this ticket + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, + /// Whether this is a paired (interactive) agent + pub paired: bool, +} + // ============================================================================= // Ticket Launch DTOs // ============================================================================= @@ -408,3 +463,79 @@ pub struct RejectReviewRequest { /// Reason for rejection (feedback for the agent) pub reason: String, } + +// ============================================================================= +// Ticket Detail DTOs +// ============================================================================= + +/// Full ticket details including content and metadata +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct TicketDetailResponse { + /// Ticket ID (e.g., "FEAT-7598") + pub id: String, + /// Ticket summary/title + pub summary: String, + /// Ticket type: FEAT, FIX, INV, SPIKE + pub ticket_type: String, + /// Project name + pub project: String, + /// Current status: queued, running, awaiting, completed + pub status: String, + /// Current step name + pub step: String, + /// Human-readable step name + #[serde(skip_serializing_if = "Option::is_none")] + pub step_display_name: Option, + /// Priority: P0-critical, P1-high, P2-medium, P3-low + pub priority: String, + /// Timestamp (YYYYMMDD-HHMM format) + pub timestamp: String, + /// Full markdown content of the ticket + pub content: String, + /// Ticket filename + pub filename: String, + /// Full filesystem path + pub filepath: String, + /// Session IDs per step (`step_name` -> `session_uuid`) + pub sessions: std::collections::HashMap, + /// Delegator used per step (`step_name` -> `delegator_name`) + pub step_delegators: std::collections::HashMap, + /// Path to git worktree (if created) + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, + /// Git branch name + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// External issue ID from kanban provider + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// URL to the issue in the external provider + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option, + /// Provider name (e.g., "jira", "linear") + #[serde(skip_serializing_if = "Option::is_none")] + pub external_provider: Option, +} + +/// Request to update a ticket's status +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateTicketStatusRequest { + /// Target status: queued, running, awaiting, done + pub status: String, +} + +/// Response from updating a ticket's status +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateTicketStatusResponse { + /// Ticket ID + pub id: String, + /// Previous status before the update + pub previous_status: String, + /// New status after the update + pub status: String, + /// Human-readable message + pub message: String, +} diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index 8e4b6a3..a597718 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -10,11 +10,15 @@ pub mod agents; pub mod configuration; pub mod issue_types; pub mod kanban; +pub mod sections; +pub mod workflow; pub use agents::*; pub use configuration::*; pub use issue_types::*; pub use kanban::*; +pub use sections::*; +pub use workflow::*; #[cfg(test)] mod tests { diff --git a/src/rest/dto/sections.rs b/src/rest/dto/sections.rs new file mode 100644 index 0000000..7eb03f1 --- /dev/null +++ b/src/rest/dto/sections.rs @@ -0,0 +1,78 @@ +//! Status section DTOs for `GET /api/v1/sections`. +//! +//! These mirror the canonical status sections shared with the TUI and the VS +//! Code extension. The section *logic* (health rules, child rows) lives in the +//! TUI layer (`crate::ui`), which the shared `rest` module cannot depend on: +//! `rest` compiles into the lib crate, and the lib crate has no `ui` module. +//! +//! To keep that boundary while still running the real section logic, the binary +//! injects a provider via [`register_section_provider`] at startup. In lib-only +//! and test contexts no provider is registered, so the endpoint returns an empty +//! list — the section logic is exercised by the ui-side builder's own tests. + +use std::sync::{Arc, OnceLock}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::config::Config; +use crate::issuetypes::IssueTypeRegistry; + +/// A child row within a status section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SectionRowDto { + /// Stable, section-scoped row id. Clients use it as a tree key and to route + /// row-specific commands without matching on the (mutable) display label. + /// Dynamic rows carry their entity key (issue-type key, project name); + /// static rows carry a fixed slug (e.g. "git-token"). + pub id: String, + /// Nesting depth within the section (1 = direct child, 2 = grandchild). + /// Lets clients rebuild the tree (e.g. LLM tools → model aliases). + pub depth: u16, + pub label: String, + pub description: String, + /// Icon hint (e.g. "check", "warning", "tool", "folder"). + pub icon: String, + /// Health: "green" | "yellow" | "red" | "gray". + pub health: String, +} + +/// A status section with its health and child rows. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SectionDto { + /// Stable section id (e.g. "config", "connections", "kanban"). + pub id: String, + pub label: String, + /// Health: "green" | "yellow" | "red" | "gray". + pub health: String, + pub description: String, + /// Section ids that must be Green before this section is usable. + pub prerequisites: Vec, + /// Whether all prerequisites are met. Sections are always returned (the web + /// UI styles unmet ones as locked) rather than hidden by progressive disclosure. + pub met: bool, + pub children: Vec, +} + +/// Builds the canonical status sections from config + the live issue-type +/// registry. Defined in the binary (the section logic is ui-layer); see module +/// docs for why this is injected rather than called directly. +pub type SectionProvider = + Arc Vec + Send + Sync>; + +static SECTION_PROVIDER: OnceLock = OnceLock::new(); + +/// Register the process-wide section provider. Called once by the binary at +/// startup, before any server starts. Subsequent calls are ignored. +pub fn register_section_provider(provider: SectionProvider) { + let _ = SECTION_PROVIDER.set(provider); +} + +/// Returns the registered provider, if any. +pub fn section_provider() -> Option { + SECTION_PROVIDER.get().cloned() +} diff --git a/src/rest/dto/workflow.rs b/src/rest/dto/workflow.rs new file mode 100644 index 0000000..b220b4a --- /dev/null +++ b/src/rest/dto/workflow.rs @@ -0,0 +1,33 @@ +//! DTOs for the workflow-export endpoint. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::workflow_gen::ExportedWorkflow; + +/// Response for exporting a ticket to a Claude dynamic workflow (`.js`). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WorkflowExportResponse { + /// The ticket the workflow was generated from. + pub ticket_id: String, + /// The issue type key that supplied the step structure. + pub issuetype_key: String, + /// Suggested filename for saving the workflow (`.workflow.js`). + pub suggested_filename: String, + /// The generated `.js` workflow source. + pub contents: String, +} + +impl From for WorkflowExportResponse { + fn from(e: ExportedWorkflow) -> Self { + Self { + ticket_id: e.ticket_id, + issuetype_key: e.issuetype_key, + suggested_filename: e.suggested_filename, + contents: e.contents, + } + } +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index a45b48e..a6f04c7 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -7,13 +7,15 @@ use std::net::SocketAddr; use anyhow::Result; use axum::{ - routing::{delete, get, post, put}, + routing::{get, post}, Router, }; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::Level; use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; use utoipa_swagger_ui::SwaggerUi; pub mod dto; @@ -22,6 +24,30 @@ pub mod openapi; pub mod routes; pub mod server; pub mod state; +#[cfg(feature = "embed-ui")] +pub mod web_ui; + +/// Shim exposing the same `EmbeddedUiState` API when the SPA isn't compiled +/// in. Callers can treat the two modules identically without `#[cfg]` blocks. +/// +/// `Ready` and `Placeholder` are never constructed in this configuration — +/// `embedded_ui_state()` always returns `Missing` when `embed-ui` is off — +/// but they must exist so call-site `match` arms remain exhaustive across +/// both feature configurations. +#[cfg(not(feature = "embed-ui"))] +pub mod web_ui { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[allow(dead_code)] + pub enum EmbeddedUiState { + Ready, + Placeholder, + Missing, + } + + pub fn embedded_ui_state() -> EmbeddedUiState { + EmbeddedUiState::Missing + } +} pub use openapi::ApiDoc; pub use server::{ApiSessionInfo, RestApiServer, RestApiStatus}; @@ -31,155 +57,146 @@ pub use state::ApiState; #[allow(dead_code)] pub const DEFAULT_PORT: u16 = 7008; -/// Build the API router with all routes -pub fn build_router(state: ApiState) -> Router { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - Router::new() +/// Build the documented API surface as a `utoipa_axum::OpenApiRouter`. +/// +/// Every always-on route is mounted here via `routes!`, so mounting a route +/// *is* registering it in the OpenAPI spec — the router and the spec cannot +/// drift. Handlers sharing a path (different HTTP methods) are grouped in a +/// single `routes!` call. Config-gated routes (MCP `sse`/`message`) are NOT +/// documented and are added separately in [`build_router`]. +/// +/// The base `OpenApi` (info, components/schemas, tags) comes from the +/// [`ApiDoc`] derive; paths and their referenced schemas are collected from the +/// mounted handlers. +fn documented_router() -> OpenApiRouter { + OpenApiRouter::with_openapi(ApiDoc::openapi()) // Health endpoints - .route("/api/v1/health", get(routes::health::health)) - .route("/api/v1/status", get(routes::health::status)) + .routes(routes!(routes::health::health)) + .routes(routes!(routes::health::status)) + // Canonical status sections (shared with the TUI / VS Code extension) + .routes(routes!(routes::sections::list)) // Issue type endpoints - .route("/api/v1/issuetypes", get(routes::issuetypes::list)) - .route("/api/v1/issuetypes", post(routes::issuetypes::create)) - .route("/api/v1/issuetypes/:key", get(routes::issuetypes::get_one)) - .route("/api/v1/issuetypes/:key", put(routes::issuetypes::update)) - .route( - "/api/v1/issuetypes/:key", - delete(routes::issuetypes::delete), - ) + .routes(routes!( + routes::issuetypes::list, + routes::issuetypes::create + )) + .routes(routes!( + routes::issuetypes::get_one, + routes::issuetypes::update, + routes::issuetypes::delete + )) // Step endpoints - .route("/api/v1/issuetypes/:key/steps", get(routes::steps::list)) - .route( - "/api/v1/issuetypes/:key/steps/:step_name", - get(routes::steps::get_one), - ) - .route( - "/api/v1/issuetypes/:key/steps/:step_name", - put(routes::steps::update), - ) + .routes(routes!(routes::steps::list)) + .routes(routes!(routes::steps::get_one, routes::steps::update)) // Collection endpoints - .route("/api/v1/collections", get(routes::collections::list)) - .route( - "/api/v1/collections/active", - get(routes::collections::get_active), - ) - .route( - "/api/v1/collections/:name", - get(routes::collections::get_one), - ) - .route( - "/api/v1/collections/:name/activate", - put(routes::collections::activate), - ) + .routes(routes!(routes::collections::list)) + .routes(routes!(routes::collections::get_active)) + .routes(routes!(routes::collections::get_one)) + .routes(routes!(routes::collections::activate)) // Queue endpoints - .route("/api/v1/queue/kanban", get(routes::queue::kanban)) - .route("/api/v1/queue/status", get(routes::queue::status)) - .route("/api/v1/queue/pause", post(routes::queue::pause)) - .route("/api/v1/queue/resume", post(routes::queue::resume)) - .route("/api/v1/queue/sync", post(routes::queue::sync)) - .route( - "/api/v1/queue/sync/:provider/:project_key", - post(routes::queue::sync_collection), - ) + .routes(routes!(routes::queue::kanban)) + .routes(routes!(routes::queue::status)) + .routes(routes!(routes::queue::pause)) + .routes(routes!(routes::queue::resume)) + .routes(routes!(routes::queue::sync)) + .routes(routes!(routes::queue::sync_collection)) // Agent endpoints - .route("/api/v1/agents/active", get(routes::agents::active)) - .route( - "/api/v1/agents/:agent_id/approve", - post(routes::agents::approve_review), - ) - .route( - "/api/v1/agents/:agent_id/reject", - post(routes::agents::reject_review), - ) + .routes(routes!(routes::agents::active)) + .routes(routes!(routes::agents::get_detail)) + .routes(routes!(routes::agents::approve_review)) + .routes(routes!(routes::agents::reject_review)) // Project endpoints - .route("/api/v1/projects", get(routes::projects::list)) - .route( - "/api/v1/projects/:name/assess", - post(routes::projects::assess), - ) + .routes(routes!(routes::projects::list)) + .routes(routes!(routes::projects::assess)) + // Ticket endpoints + .routes(routes!(routes::tickets::get_one)) + .routes(routes!(routes::tickets::update_status)) // Launch endpoints - .route( - "/api/v1/tickets/:id/launch", - post(routes::launch::launch_ticket), - ) + .routes(routes!(routes::launch::launch_ticket)) + // Workflow export endpoint + .routes(routes!(routes::workflow::export)) // Step completion endpoint (for opr8r wrapper) - .route( - "/api/v1/tickets/:id/steps/:step/complete", - post(routes::launch::complete_step), - ) + .routes(routes!(routes::launch::complete_step)) // Kanban provider endpoints - .route( - "/api/v1/kanban/:provider/:project_key/issuetypes", - get(routes::kanban::external_issue_types), - ) - .route( - "/api/v1/kanban/:provider/:project_key/issuetypes/sync", - post(routes::kanban::sync_issue_types), - ) + .routes(routes!(routes::kanban::external_issue_types)) + .routes(routes!(routes::kanban::sync_issue_types)) // Kanban onboarding endpoints (validate, list projects, write config, set env) - .route( - "/api/v1/kanban/validate", - post(routes::kanban_onboarding::validate_credentials), - ) - .route( - "/api/v1/kanban/projects", - post(routes::kanban_onboarding::list_projects), - ) - .route( - "/api/v1/kanban/config", - put(routes::kanban_onboarding::write_config), - ) - .route( - "/api/v1/kanban/session-env", - post(routes::kanban_onboarding::set_session_env), - ) + .routes(routes!(routes::kanban_onboarding::validate_credentials)) + .routes(routes!(routes::kanban_onboarding::list_projects)) + .routes(routes!(routes::kanban_onboarding::write_config)) + .routes(routes!(routes::kanban_onboarding::set_session_env)) // Skills endpoint - .route("/api/v1/skills", get(routes::skills::list)) + .routes(routes!(routes::skills::list)) // LLM tools endpoints - .route("/api/v1/llm-tools", get(routes::llm_tools::list)) - .route( - "/api/v1/llm-tools/default", - get(routes::llm_tools::get_default).put(routes::llm_tools::set_default), - ) - // Delegator endpoints - .route("/api/v1/delegators", get(routes::delegators::list)) - .route("/api/v1/delegators", post(routes::delegators::create)) - // from-tool must be registered before :name to avoid path capture - .route( - "/api/v1/delegators/from-tool", - post(routes::delegators::create_from_tool), - ) - .route("/api/v1/delegators/:name", get(routes::delegators::get_one)) - .route("/api/v1/delegators/:name", put(routes::delegators::update)) - .route( - "/api/v1/delegators/:name", - delete(routes::delegators::delete), - ) + .routes(routes!(routes::llm_tools::list)) + .routes(routes!( + routes::llm_tools::get_default, + routes::llm_tools::set_default + )) + // Delegator endpoints. `from-tool` is a distinct static path; axum 0.7 + // prefers static segments over `{name}`, so ordering is not required for + // correctness, but the routes stay grouped by path for clarity. + .routes(routes!( + routes::delegators::list, + routes::delegators::create + )) + .routes(routes!(routes::delegators::create_from_tool)) + .routes(routes!( + routes::delegators::get_one, + routes::delegators::update, + routes::delegators::delete + )) + // Configuration endpoints + .routes(routes!( + routes::configuration::get_config, + routes::configuration::update_config + )) // Model server endpoints - .route("/api/v1/model-servers", get(routes::model_servers::list)) - .route("/api/v1/model-servers", post(routes::model_servers::create)) - .route( - "/api/v1/model-servers/:name", - get(routes::model_servers::get_one), - ) - .route( - "/api/v1/model-servers/:name", - delete(routes::model_servers::delete), - ) - // MCP endpoints - .route( - "/api/v1/mcp/descriptor", - get(crate::mcp::descriptor::descriptor), - ) - .route("/api/v1/mcp/sse", get(crate::mcp::transport::sse_handler)) - .route( - "/api/v1/mcp/message", - post(crate::mcp::transport::message_handler), - ) + .routes(routes!( + routes::model_servers::list, + routes::model_servers::create + )) + .routes(routes!( + routes::model_servers::get_one, + routes::model_servers::delete + )) + // MCP descriptor — always mounted so non-HTTP MCP clients can still + // discover the stdio entrypoint. + .routes(routes!(crate::mcp::descriptor::descriptor)) +} + +/// The canonical OpenAPI spec for the documented API surface. +/// +/// Built from [`documented_router`] so it always reflects the mounted routes. +/// Config-gated MCP transport routes are omitted (they carry no +/// `#[utoipa::path]` and only ever exist when `[mcp].http_enabled`). +pub fn openapi_spec() -> utoipa::openapi::OpenApi { + documented_router().split_for_parts().1 +} + +/// Build the API router with all routes +pub fn build_router(state: ApiState) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let mcp_enabled = state.config.mcp.http_enabled; + + let (mut router, api) = documented_router().split_for_parts(); + + // MCP transport endpoints — gated by [mcp].http_enabled and intentionally + // undocumented (no OpenAPI schema for the SSE/JSON-RPC transport). + if mcp_enabled { + router = router + .route("/api/v1/mcp/sse", get(crate::mcp::transport::sse_handler)) + .route( + "/api/v1/mcp/message", + post(crate::mcp::transport::message_handler), + ); + } + + let router = router .layer( TraceLayer::new_for_http() .on_request(DefaultOnRequest::new().level(Level::INFO)) @@ -187,7 +204,12 @@ pub fn build_router(state: ApiState) -> Router { ) .layer(cors) .with_state(state) - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api)); + + #[cfg(feature = "embed-ui")] + let router = router.fallback(web_ui::spa_handler); + + router } /// Start the REST API server (standalone mode with session file and logging) diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 80ff438..3f7593e 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -4,12 +4,22 @@ use utoipa::OpenApi; use crate::mcp::descriptor::McpDescriptorResponse; use crate::rest::dto::{ + ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, AssessTicketResponse, CollectionResponse, CreateDelegatorFromToolRequest, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateModelServerRequest, CreateStepRequest, DefaultLlmResponse, - DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, - IssueTypeResponse, IssueTypeSummary, LaunchTicketRequest, LaunchTicketResponse, - ModelServerResponse, ModelServersResponse, SetDefaultLlmRequest, SkillEntry, SkillsResponse, - StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, ExternalIssueTypeSummary, + FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, + KanbanIssueTypeResponse, KanbanSyncResponse, KanbanTicketCard, LaunchTicketRequest, + LaunchTicketResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, + ModelServerResponse, ModelServersResponse, NextStepInfo, OperatorOutput, ProjectSummary, + QueueByType, QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ReviewResponse, + SectionDto, SectionRowDto, SetDefaultLlmRequest, SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, StatusResponse, StepCompleteRequest, + StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, + UpdateIssueTypeRequest, UpdateStepRequest, UpdateTicketStatusRequest, + UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, WorkflowExportResponse, WriteKanbanConfigRequest, + WriteKanbanConfigResponse, }; use crate::rest::error::ErrorResponse; @@ -26,53 +36,17 @@ use crate::rest::error::ErrorResponse; url = "https://github.com/untra/operator" ) ), - paths( - // Health endpoints - crate::rest::routes::health::health, - crate::rest::routes::health::status, - // Issue type endpoints - crate::rest::routes::issuetypes::list, - crate::rest::routes::issuetypes::get_one, - crate::rest::routes::issuetypes::create, - crate::rest::routes::issuetypes::update, - crate::rest::routes::issuetypes::delete, - // Step endpoints - crate::rest::routes::steps::list, - crate::rest::routes::steps::get_one, - crate::rest::routes::steps::update, - // Collection endpoints - crate::rest::routes::collections::list, - crate::rest::routes::collections::get_active, - crate::rest::routes::collections::get_one, - crate::rest::routes::collections::activate, - // Launch endpoints - crate::rest::routes::launch::launch_ticket, - // Skills endpoints - crate::rest::routes::skills::list, - // Delegator endpoints - crate::rest::routes::delegators::list, - crate::rest::routes::delegators::get_one, - crate::rest::routes::delegators::create, - crate::rest::routes::delegators::create_from_tool, - crate::rest::routes::delegators::update, - crate::rest::routes::delegators::delete, - // LLM tools endpoints - crate::rest::routes::llm_tools::list, - crate::rest::routes::llm_tools::get_default, - crate::rest::routes::llm_tools::set_default, - // Model server endpoints - crate::rest::routes::model_servers::list, - crate::rest::routes::model_servers::get_one, - crate::rest::routes::model_servers::create, - crate::rest::routes::model_servers::delete, - // MCP endpoints - crate::mcp::descriptor::descriptor, - ), + // NOTE: `paths(...)` is intentionally omitted. Routes self-register in the + // OpenAPI spec when mounted via `utoipa_axum::routes!` in + // `crate::rest::build_router` — mounting a route *is* documenting it, so the + // two can no longer drift. See `crate::rest::openapi_spec`. components( schemas( // Response types HealthResponse, StatusResponse, + SectionDto, + SectionRowDto, IssueTypeResponse, IssueTypeSummary, FieldResponse, @@ -103,40 +77,91 @@ use crate::rest::error::ErrorResponse; // LLM tools types SetDefaultLlmRequest, DefaultLlmResponse, + // Ticket types + TicketDetailResponse, + UpdateTicketStatusRequest, + UpdateTicketStatusResponse, + // Workflow export types + WorkflowExportResponse, // MCP types McpDescriptorResponse, + // Queue types + KanbanBoardResponse, + KanbanTicketCard, + QueueStatusResponse, + QueueByType, + QueueControlResponse, + KanbanSyncResponse, + // Agent types + ActiveAgentsResponse, + ActiveAgentResponse, + AgentDetailResponse, + ReviewResponse, + RejectReviewRequest, + OperatorOutput, + // Project types + ProjectSummary, + AssessTicketResponse, + // Launch step-completion types + StepCompleteRequest, + StepCompleteResponse, + NextStepInfo, + // Kanban provider types + ExternalIssueTypeSummary, + KanbanIssueTypeResponse, + SyncKanbanIssueTypesResponse, + // Kanban onboarding types + ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, + ListKanbanProjectsRequest, + ListKanbanProjectsResponse, + WriteKanbanConfigRequest, + WriteKanbanConfigResponse, + SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, ) ), tags( (name = "Health", description = "Health check and status endpoints"), + (name = "Status", description = "Canonical status sections (TUI / VS Code parity)"), (name = "Issue Types", description = "Issue type CRUD operations"), (name = "Steps", description = "Step management within issue types"), (name = "Collections", description = "Issue type collection management"), + (name = "Tickets", description = "Ticket CRUD and status management"), (name = "Launch", description = "Ticket launch operations"), + (name = "Workflow", description = "Export tickets to Claude dynamic workflows"), (name = "Skills", description = "Skill discovery across LLM tools"), (name = "Delegators", description = "Agent delegator CRUD operations"), (name = "ModelServers", description = "Model server (ollama, openai-compat, etc.) CRUD operations"), (name = "MCP", description = "Model Context Protocol integration"), + (name = "Queue", description = "Ticket queue board, status, and control"), + (name = "Agents", description = "Active agent tracking and review actions"), + (name = "Projects", description = "Project discovery and ticket assessment"), + (name = "Configuration", description = "Operator configuration read/write"), + (name = "Kanban", description = "Kanban provider issue types and onboarding"), ) )] pub struct ApiDoc; impl ApiDoc { - /// Generate the OpenAPI specification as a JSON string + /// Generate the OpenAPI specification as a JSON string. /// - /// The version is automatically derived from Cargo.toml to stay in sync. + /// Sourced from the fully-mounted router via [`crate::rest::openapi_spec`] + /// so every live route appears in the spec (the bare `ApiDoc` derive carries + /// only info/components/tags — paths self-register on mount). The version is + /// automatically derived from Cargo.toml to stay in sync. pub fn json() -> Result { - let mut spec = Self::openapi(); + let mut spec = crate::rest::openapi_spec(); spec.info.version = env!("CARGO_PKG_VERSION").to_string(); serde_json::to_string_pretty(&spec) } - /// Generate the OpenAPI specification as a YAML string + /// Generate the OpenAPI specification as a YAML string. /// /// The version is automatically derived from Cargo.toml to stay in sync. #[allow(dead_code)] pub fn yaml() -> Result { - let mut spec = Self::openapi(); + let mut spec = crate::rest::openapi_spec(); spec.info.version = env!("CARGO_PKG_VERSION").to_string(); serde_yaml::to_string(&spec) } @@ -163,6 +188,65 @@ mod tests { assert!(spec.contains("\"Collections\"")); } + #[test] + fn test_openapi_operation_ids_are_unique() { + // Structural guard: utoipa derives operationId from the bare fn name, so + // collisions (multiple `list` / `get_one` / `create`) silently produce + // an invalid spec that breaks downstream codegen. Every `#[utoipa::path]` + // sets an explicit `module_fn` operation_id; this asserts they stay + // globally unique as routes are added. + let spec: serde_json::Value = + serde_json::from_str(&ApiDoc::json().expect("generate spec")).expect("parse spec"); + + let mut ids: Vec = Vec::new(); + let paths = spec["paths"].as_object().expect("paths object"); + for (path, item) in paths { + let methods = item.as_object().expect("path item object"); + for (method, op) in methods { + let oid = op + .get("operationId") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + panic!("{} {path} is missing an operationId", method.to_uppercase()) + }); + ids.push(oid.to_string()); + } + } + + let mut seen = std::collections::HashSet::new(); + let mut dups: Vec<&String> = ids.iter().filter(|id| !seen.insert(*id)).collect(); + dups.sort(); + dups.dedup(); + assert!( + dups.is_empty(), + "duplicate operationId(s) in OpenAPI spec: {dups:?}" + ); + assert!( + !ids.is_empty(), + "expected at least one documented operation" + ); + } + + #[test] + fn test_openapi_includes_previously_undocumented_routes() { + // Regression guard for the drift this migration fixed: these routes are + // mounted by `build_router` and must appear in the generated spec. + let spec = ApiDoc::json().expect("generate spec"); + for path in [ + "/api/v1/queue/kanban", + "/api/v1/agents/active", + "/api/v1/projects", + "/api/v1/configuration", + "/api/v1/kanban/validate", + "/api/v1/tickets/{id}/steps/{step}/complete", + ] { + assert!( + spec.contains(path), + "spec should document the mounted route {path}" + ); + } + } + #[test] fn test_openapi_version_matches_cargo() { let spec = ApiDoc::json().expect("Failed to generate OpenAPI spec"); diff --git a/src/rest/routes/agents.rs b/src/rest/routes/agents.rs index b58eb69..a51546a 100644 --- a/src/rest/routes/agents.rs +++ b/src/rest/routes/agents.rs @@ -8,7 +8,8 @@ use axum::{ }; use crate::rest::dto::{ - ActiveAgentResponse, ActiveAgentsResponse, RejectReviewRequest, ReviewResponse, + ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, RejectReviewRequest, + ReviewResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; @@ -18,6 +19,7 @@ use crate::state::State as OperatorState; /// /// Returns a list of all currently running agents with their status and details. #[utoipa::path( + operation_id = "agents_active", get, path = "/api/v1/agents/active", tag = "Agents", @@ -62,11 +64,63 @@ pub async fn active(State(state): State) -> Result, + Path(agent_id): Path, +) -> Result, ApiError> { + let operator_state = OperatorState::load(&state.config) + .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; + + let agent = operator_state + .agents + .iter() + .find(|a| a.id == agent_id) + .ok_or_else(|| ApiError::NotFound(format!("Agent '{agent_id}' not found")))?; + + Ok(Json(AgentDetailResponse { + id: agent.id.clone(), + ticket_id: agent.ticket_id.clone(), + ticket_type: agent.ticket_type.clone(), + project: agent.project.clone(), + status: agent.status.clone(), + started_at: agent.started_at.to_rfc3339(), + last_activity: agent.last_activity.to_rfc3339(), + current_step: agent.current_step.clone(), + llm_tool: agent.llm_tool.clone(), + llm_model: agent.llm_model.clone(), + launch_mode: agent.launch_mode.clone(), + pr_url: agent.pr_url.clone(), + pr_status: agent.pr_status.clone(), + session_wrapper: agent.session_wrapper.clone(), + review_state: agent.review_state.clone(), + completed_steps: agent.completed_steps.clone(), + worktree_path: agent.worktree_path.clone(), + paired: agent.paired, + })) +} + /// Approve an agent's pending review /// /// Clears the review state and signals the agent to continue. /// The agent must be in `awaiting_input` status with a pending review. #[utoipa::path( + operation_id = "agents_approve_review", post, path = "/api/v1/agents/{agent_id}/approve", tag = "Agents", @@ -124,6 +178,7 @@ pub async fn approve_review( /// Signals the agent that the review was rejected with feedback. /// The agent should re-do the work based on the rejection reason. #[utoipa::path( + operation_id = "agents_reject_review", post, path = "/api/v1/agents/{agent_id}/reject", tag = "Agents", diff --git a/src/rest/routes/collections.rs b/src/rest/routes/collections.rs index 348e36c..2c38b37 100644 --- a/src/rest/routes/collections.rs +++ b/src/rest/routes/collections.rs @@ -11,6 +11,7 @@ use crate::rest::state::ApiState; /// List all collections #[utoipa::path( + operation_id = "collections_list", get, path = "/api/v1/collections", tag = "Collections", @@ -32,6 +33,7 @@ pub async fn list(State(state): State) -> Json /// Get the currently active collection #[utoipa::path( + operation_id = "collections_get_active", get, path = "/api/v1/collections/active", tag = "Collections", @@ -54,6 +56,7 @@ pub async fn get_active( /// Get a single collection by name #[utoipa::path( + operation_id = "collections_get_one", get, path = "/api/v1/collections/{name}", tag = "Collections", @@ -84,6 +87,7 @@ pub async fn get_one( /// Activate a collection #[utoipa::path( + operation_id = "collections_activate", put, path = "/api/v1/collections/{name}/activate", tag = "Collections", diff --git a/src/rest/routes/configuration.rs b/src/rest/routes/configuration.rs new file mode 100644 index 0000000..f9cb312 --- /dev/null +++ b/src/rest/routes/configuration.rs @@ -0,0 +1,65 @@ +//! Configuration read/write endpoints. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; + +use crate::config::Config; +use crate::rest::state::ApiState; + +/// Get the current configuration +/// +/// Returns the full operator configuration as a JSON object. The body is left +/// opaque in the OpenAPI spec because the `Config` tree is large and no client +/// consumes its OpenAPI schema (the TS `Config` type is generated separately by +/// ts-rs). +#[utoipa::path( + get, + path = "/api/v1/configuration", + tag = "Configuration", + operation_id = "configuration_get", + responses( + (status = 200, description = "Current configuration as a JSON object", body = serde_json::Value) + ) +)] +pub async fn get_config(State(state): State) -> Json { + Json((*state.config).clone()) +} + +/// Update configuration and save to disk +#[utoipa::path( + put, + path = "/api/v1/configuration", + tag = "Configuration", + operation_id = "configuration_update", + request_body = serde_json::Value, + responses( + (status = 200, description = "Updated configuration as a JSON object", body = serde_json::Value), + (status = 500, description = "Failed to save configuration") + ) +)] +pub async fn update_config( + State(state): State, + Json(incoming): Json, +) -> Result, (StatusCode, String)> { + incoming + .save() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let _ = &state; + Ok(Json(incoming)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[tokio::test] + async fn test_get_config() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let Json(cfg) = get_config(State(state)).await; + assert!(!cfg.projects.is_empty() || cfg.projects.is_empty()); + } +} diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 4a900c5..65b4938 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -18,6 +18,7 @@ use crate::rest::state::ApiState; /// List all configured delegators #[utoipa::path( + operation_id = "delegators_list", get, path = "/api/v1/delegators", tag = "Delegators", @@ -38,6 +39,7 @@ pub async fn list(State(state): State) -> Json { /// Get a single delegator by name #[utoipa::path( + operation_id = "delegators_get_one", get, path = "/api/v1/delegators/{name}", tag = "Delegators", @@ -65,6 +67,7 @@ pub async fn get_one( /// Create a new delegator #[utoipa::path( + operation_id = "delegators_create", post, path = "/api/v1/delegators", tag = "Delegators", @@ -108,6 +111,7 @@ pub async fn create( /// Delete a delegator by name #[utoipa::path( + operation_id = "delegators_delete", delete, path = "/api/v1/delegators/{name}", tag = "Delegators", @@ -189,6 +193,7 @@ fn delegator_to_response(d: &Delegator) -> DelegatorResponse { /// /// Pre-populates delegator fields from the detected tool, requiring minimal input. #[utoipa::path( + operation_id = "delegators_create_from_tool", post, path = "/api/v1/delegators/from-tool", tag = "Delegators", @@ -254,6 +259,7 @@ pub async fn create_from_tool( /// Update an existing delegator #[utoipa::path( + operation_id = "delegators_update", put, path = "/api/v1/delegators/{name}", tag = "Delegators", diff --git a/src/rest/routes/health.rs b/src/rest/routes/health.rs index a5ef344..6f437ce 100644 --- a/src/rest/routes/health.rs +++ b/src/rest/routes/health.rs @@ -7,6 +7,7 @@ use crate::rest::state::ApiState; /// Health check endpoint #[utoipa::path( + operation_id = "health_check", get, path = "/api/v1/health", tag = "Health", @@ -23,6 +24,7 @@ pub async fn health() -> Json { /// Get service status with registry info #[utoipa::path( + operation_id = "health_status", get, path = "/api/v1/status", tag = "Health", diff --git a/src/rest/routes/issuetypes.rs b/src/rest/routes/issuetypes.rs index 2e9713e..90ff6c8 100644 --- a/src/rest/routes/issuetypes.rs +++ b/src/rest/routes/issuetypes.rs @@ -14,6 +14,7 @@ use crate::rest::state::ApiState; /// List all issue types #[utoipa::path( + operation_id = "issuetypes_list", get, path = "/api/v1/issuetypes", tag = "Issue Types", @@ -29,6 +30,7 @@ pub async fn list(State(state): State) -> Json> /// Get a single issue type by key #[utoipa::path( + operation_id = "issuetypes_get_one", get, path = "/api/v1/issuetypes/{key}", tag = "Issue Types", @@ -54,6 +56,7 @@ pub async fn get_one( /// Create a new issue type #[utoipa::path( + operation_id = "issuetypes_create", post, path = "/api/v1/issuetypes", tag = "Issue Types", @@ -109,6 +112,7 @@ pub async fn create( /// Update an existing issue type #[utoipa::path( + operation_id = "issuetypes_update", put, path = "/api/v1/issuetypes/{key}", tag = "Issue Types", @@ -201,6 +205,7 @@ pub async fn update( /// Delete an issue type #[utoipa::path( + operation_id = "issuetypes_delete", delete, path = "/api/v1/issuetypes/{key}", tag = "Issue Types", diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index ec17fa6..9513959 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -16,6 +16,21 @@ use crate::services::kanban_issuetype_service::KanbanIssueTypeService; /// /// Returns kanban issue types from the persisted catalog for a given provider/project. /// Falls back to fetching live from the provider if no catalog exists. +#[utoipa::path( + get, + path = "/api/v1/kanban/{provider}/{project_key}/issuetypes", + tag = "Kanban", + operation_id = "kanban_external_issue_types", + params( + ("provider" = String, Path, description = "Kanban provider name (e.g. jira, linear, github)"), + ("project_key" = String, Path, description = "Provider project/team key") + ), + responses( + (status = 200, description = "External issue types", body = Vec), + (status = 400, description = "Unknown provider/project"), + (status = 500, description = "Failed to read catalog or fetch from provider") + ) +)] pub async fn external_issue_types( State(state): State, Path((provider_name, project_key)): Path<(String, String)>, @@ -68,6 +83,21 @@ pub async fn external_issue_types( /// POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync` /// /// Refreshes the local kanban issue type catalog from the provider. +#[utoipa::path( + post, + path = "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync", + tag = "Kanban", + operation_id = "kanban_sync_issue_types", + params( + ("provider" = String, Path, description = "Kanban provider name (e.g. jira, linear, github)"), + ("project_key" = String, Path, description = "Provider project/team key") + ), + responses( + (status = 200, description = "Synced issue types", body = SyncKanbanIssueTypesResponse), + (status = 400, description = "Unknown provider/project"), + (status = 500, description = "Failed to sync from provider") + ) +)] pub async fn sync_issue_types( State(state): State, Path((provider_name, project_key)): Path<(String, String)>, diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index 26d937d..3771751 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -21,6 +21,16 @@ use crate::services::kanban_onboarding; /// Validate credentials against the live provider API without persisting /// anything. Auth failures return `valid: false` with an `error` string /// rather than a 4xx/5xx status so clients can display errors inline. +#[utoipa::path( + post, + path = "/api/v1/kanban/validate", + tag = "Kanban", + operation_id = "kanban_validate_credentials", + request_body = ValidateKanbanCredentialsRequest, + responses( + (status = 200, description = "Validation result (valid flag + optional error)", body = ValidateKanbanCredentialsResponse) + ) +)] pub async fn validate_credentials( State(_state): State, Json(req): Json, @@ -33,6 +43,16 @@ pub async fn validate_credentials( /// /// List available projects/teams for the given provider using ephemeral /// credentials. No persistence side effects. +#[utoipa::path( + post, + path = "/api/v1/kanban/projects", + tag = "Kanban", + operation_id = "kanban_list_projects", + request_body = ListKanbanProjectsRequest, + responses( + (status = 200, description = "Available projects/teams for the provider", body = ListKanbanProjectsResponse) + ) +)] pub async fn list_projects( State(_state): State, Json(req): Json, @@ -45,6 +65,16 @@ pub async fn list_projects( /// /// Write or upsert a kanban provider+project section into `config.toml`. /// Does NOT receive the actual secret — only the env var name (`api_key_env`). +#[utoipa::path( + put, + path = "/api/v1/kanban/config", + tag = "Kanban", + operation_id = "kanban_write_config", + request_body = WriteKanbanConfigRequest, + responses( + (status = 200, description = "Config section written/upserted", body = WriteKanbanConfigResponse) + ) +)] pub async fn write_config( State(_state): State, Json(req): Json, @@ -59,6 +89,16 @@ pub async fn write_config( /// Set kanban env vars on the server process for the current session so /// subsequent `from_config()` calls find the API key. Returns a /// `shell_export_block` with placeholder values for the client to display. +#[utoipa::path( + post, + path = "/api/v1/kanban/session-env", + tag = "Kanban", + operation_id = "kanban_set_session_env", + request_body = SetKanbanSessionEnvRequest, + responses( + (status = 200, description = "Session env vars set; returns a shell export block", body = SetKanbanSessionEnvResponse) + ) +)] pub async fn set_session_env( State(_state): State, Json(req): Json, diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index ada940f..08a5db1 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -137,6 +137,7 @@ fn prepared_launch_to_response(prepared: PreparedLaunch) -> LaunchTicketResponse /// Claims the ticket, sets up worktree if needed, generates the LLM command, /// and returns all details needed to execute in an external terminal (VS Code, etc.). #[utoipa::path( + operation_id = "launch_launch_ticket", post, path = "/api/v1/tickets/{id}/launch", tag = "Launch", @@ -252,6 +253,7 @@ fn build_relaunch_options( /// Called by the opr8r wrapper when an LLM command completes. /// Returns next step info and whether to auto-proceed. #[utoipa::path( + operation_id = "launch_complete_step", post, path = "/api/v1/tickets/{id}/steps/{step}/complete", tag = "Launch", diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs index 8de3199..bd98119 100644 --- a/src/rest/routes/llm_tools.rs +++ b/src/rest/routes/llm_tools.rs @@ -13,6 +13,7 @@ use crate::rest::state::ApiState; /// List detected LLM tools with model aliases #[utoipa::path( + operation_id = "llm_tools_list", get, path = "/api/v1/llm-tools", tag = "LLM Tools", @@ -28,6 +29,7 @@ pub async fn list(State(state): State) -> Json { /// Get the current default LLM tool and model #[utoipa::path( + operation_id = "llm_tools_get_default", get, path = "/api/v1/llm-tools/default", tag = "LLM Tools", @@ -54,6 +56,7 @@ pub async fn get_default(State(state): State) -> Json ModelServerRespon /// List all model servers (user-declared + implicit builtins) #[utoipa::path( + operation_id = "model_servers_list", get, path = "/api/v1/model-servers", tag = "ModelServers", @@ -59,6 +60,7 @@ pub async fn list(State(state): State) -> Json { /// Get a single model server by name #[utoipa::path( + operation_id = "model_servers_get_one", get, path = "/api/v1/model-servers/{name}", tag = "ModelServers", @@ -90,6 +92,7 @@ pub async fn get_one( /// Create a new model server #[utoipa::path( + operation_id = "model_servers_create", post, path = "/api/v1/model-servers", tag = "ModelServers", @@ -146,6 +149,7 @@ pub async fn create( /// /// Implicit builtin servers cannot be deleted. #[utoipa::path( + operation_id = "model_servers_delete", delete, path = "/api/v1/model-servers/{name}", tag = "ModelServers", diff --git a/src/rest/routes/projects.rs b/src/rest/routes/projects.rs index 4dd5346..501a6ea 100644 --- a/src/rest/routes/projects.rs +++ b/src/rest/routes/projects.rs @@ -5,15 +5,16 @@ use axum::{ Json, }; -use crate::backstage::analyzer::ProjectAnalysis; use crate::queue::creator::{render_template, TicketCreator}; use crate::rest::dto::{AssessTicketResponse, ProjectSummary}; use crate::rest::error::ApiError; use crate::rest::state::ApiState; +use crate::taxonomy::analyzer::ProjectAnalysis; use crate::templates::TemplateType; /// List all configured projects with analysis data #[utoipa::path( + operation_id = "projects_list", get, path = "/api/v1/projects", tag = "Projects", @@ -124,6 +125,7 @@ pub async fn list(State(state): State) -> Json> { /// Create an ASSESS ticket for a project #[utoipa::path( + operation_id = "projects_assess", post, path = "/api/v1/projects/{name}/assess", tag = "Projects", @@ -155,7 +157,7 @@ pub async fn assess( let mut values = creator.generate_default_values(template_type, &name); values.insert( "summary".to_string(), - format!("Assess {name} for Backstage catalog"), + format!("Assess {name} project structure"), ); // Render template content diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index 915cca2..8ec1f02 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -39,6 +39,7 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { /// Returns tickets organized into four columns: queue, running, awaiting, done. /// Tickets are sorted by priority within each column, then by timestamp (FIFO). #[utoipa::path( + operation_id = "queue_kanban", get, path = "/api/v1/queue/kanban", tag = "Queue", @@ -140,6 +141,7 @@ pub async fn kanban(State(state): State) -> Result) -> Result) -> Result) -> Result) -> Result) + ) +)] +pub async fn list(State(state): State) -> Json> { + match section_provider() { + Some(provider) => { + let registry = state.registry.read().await; + Json(provider(&state.config, ®istry)) + } + None => Json(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + #[tokio::test] + async fn test_sections_empty_when_no_provider_registered() { + // The binary registers the provider at startup; tests never do, so the + // endpoint returns an empty list here. The real section logic is covered + // by the ui-side `build_section_dtos` tests. + let state = ApiState::new(Config::default(), PathBuf::from("/tmp/test-sections")); + let resp = list(State(state)).await; + assert!(resp.0.is_empty()); + } +} diff --git a/src/rest/routes/skills.rs b/src/rest/routes/skills.rs index 9668974..b1ae49f 100644 --- a/src/rest/routes/skills.rs +++ b/src/rest/routes/skills.rs @@ -10,6 +10,7 @@ use crate::rest::state::ApiState; /// List all discovered skills across LLM tools #[utoipa::path( + operation_id = "skills_list", get, path = "/api/v1/skills", tag = "Skills", diff --git a/src/rest/routes/steps.rs b/src/rest/routes/steps.rs index 20ab710..a163d5c 100644 --- a/src/rest/routes/steps.rs +++ b/src/rest/routes/steps.rs @@ -13,6 +13,7 @@ use crate::templates::schema::{PermissionMode, ReviewType, StepOutput}; /// List all steps for an issue type #[utoipa::path( + operation_id = "steps_list", get, path = "/api/v1/issuetypes/{key}/steps", tag = "Steps", @@ -39,6 +40,7 @@ pub async fn list( /// Get a single step by name #[utoipa::path( + operation_id = "steps_get_one", get, path = "/api/v1/issuetypes/{key}/steps/{step_name}", tag = "Steps", @@ -69,6 +71,7 @@ pub async fn get_one( /// Update a step #[utoipa::path( + operation_id = "steps_update", put, path = "/api/v1/issuetypes/{key}/steps/{step_name}", tag = "Steps", diff --git a/src/rest/routes/tickets.rs b/src/rest/routes/tickets.rs new file mode 100644 index 0000000..747917e --- /dev/null +++ b/src/rest/routes/tickets.rs @@ -0,0 +1,206 @@ +//! Ticket CRUD endpoints for the REST API. +//! +//! Provides endpoints for fetching ticket details and updating ticket status. +//! These endpoints power the embedded web UI's kanban board and detail drawer. + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::queue::{Queue, Ticket}; +use crate::rest::dto::{ + TicketDetailResponse, UpdateTicketStatusRequest, UpdateTicketStatusResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; + +/// Find a ticket across all directories (queue, in-progress, completed) +pub(crate) fn find_ticket_anywhere(queue: &Queue, ticket_id: &str) -> Result { + // find_ticket searches queue + in-progress + if let Some(ticket) = queue + .find_ticket(ticket_id) + .map_err(|e| ApiError::InternalError(e.to_string()))? + { + return Ok(ticket); + } + + // Also search completed + let completed = queue + .list_completed() + .map_err(|e| ApiError::InternalError(e.to_string()))?; + completed + .into_iter() + .find(|t| t.id == ticket_id || t.filename.contains(ticket_id)) + .ok_or_else(|| ApiError::NotFound(format!("Ticket '{ticket_id}' not found"))) +} + +/// Get full ticket details by ID +/// +/// Returns complete ticket data including content, metadata, step history, +/// and session information. Searches queue, in-progress, and completed directories. +#[utoipa::path( + operation_id = "tickets_get_one", + get, + path = "/api/v1/tickets/{id}", + tag = "Tickets", + params( + ("id" = String, Path, description = "Ticket ID (e.g., FEAT-7598)") + ), + responses( + (status = 200, description = "Ticket details", body = TicketDetailResponse), + (status = 404, description = "Ticket not found") + ) +)] +pub async fn get_one( + State(state): State, + Path(ticket_id): Path, +) -> Result, ApiError> { + let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + + let ticket = find_ticket_anywhere(&queue, &ticket_id)?; + let step_display_name = ticket.current_step_display_name(); + + Ok(Json(TicketDetailResponse { + id: ticket.id, + summary: ticket.summary, + ticket_type: ticket.ticket_type, + project: ticket.project, + status: ticket.status, + step: ticket.step, + step_display_name: if step_display_name.is_empty() { + None + } else { + Some(step_display_name) + }, + priority: ticket.priority, + timestamp: ticket.timestamp, + content: ticket.content, + filename: ticket.filename, + filepath: ticket.filepath, + sessions: ticket.sessions, + step_delegators: ticket.step_delegators, + worktree_path: ticket.worktree_path, + branch: ticket.branch, + external_id: ticket.external_id, + external_url: ticket.external_url, + external_provider: ticket.external_provider, + })) +} + +/// Update a ticket's status +/// +/// Moves a ticket between queue directories based on the target status. +/// Valid transitions: queued, running, awaiting, done. +#[utoipa::path( + operation_id = "tickets_update_status", + put, + path = "/api/v1/tickets/{id}/status", + tag = "Tickets", + params( + ("id" = String, Path, description = "Ticket ID to update") + ), + request_body = UpdateTicketStatusRequest, + responses( + (status = 200, description = "Ticket status updated", body = UpdateTicketStatusResponse), + (status = 400, description = "Invalid status value"), + (status = 404, description = "Ticket not found") + ) +)] +pub async fn update_status( + State(state): State, + Path(ticket_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let valid_statuses = ["queued", "running", "awaiting", "done"]; + if !valid_statuses.contains(&request.status.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid status '{}'. Must be one of: {}", + request.status, + valid_statuses.join(", ") + ))); + } + + let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + + let ticket = find_ticket_anywhere(&queue, &ticket_id)?; + + let previous_status = ticket.status.clone(); + let target_status = request.status.as_str(); + + // Determine target directory + let tickets_path = state.config.tickets_path(); + let dst_dir = match target_status { + "queued" => tickets_path.join("queue"), + "running" | "awaiting" => tickets_path.join("in-progress"), + "done" => tickets_path.join("completed"), + _ => unreachable!(), + }; + + let src = std::path::PathBuf::from(&ticket.filepath); + let dst = dst_dir.join(&ticket.filename); + + // Ensure target directory exists + std::fs::create_dir_all(&dst_dir) + .map_err(|e| ApiError::InternalError(format!("Failed to create directory: {e}")))?; + + // Move the file if source and destination differ + if src != dst { + std::fs::rename(&src, &dst) + .map_err(|e| ApiError::InternalError(format!("Failed to move ticket: {e}")))?; + } + + // Update the status field in the ticket file + if previous_status != target_status { + let mut moved_ticket = Ticket::from_file(&dst) + .map_err(|e| ApiError::InternalError(format!("Failed to reload ticket: {e}")))?; + moved_ticket + .update_field("status", target_status) + .map_err(|e| ApiError::InternalError(format!("Failed to update status field: {e}")))?; + } + + Ok(Json(UpdateTicketStatusResponse { + id: ticket.id, + previous_status, + status: target_status.to_string(), + message: format!("Ticket moved to '{target_status}'"), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn make_state() -> ApiState { + let config = Config::default(); + ApiState::new(config, PathBuf::from("/tmp/test-tickets")) + } + + #[test] + fn test_valid_statuses() { + let valid = ["queued", "running", "awaiting", "done"]; + for s in &valid { + assert!(valid.contains(s)); + } + assert!(!valid.contains(&"invalid")); + } + + #[tokio::test] + async fn test_get_ticket_not_found() { + let state = make_state(); + let result = get_one(State(state), Path("NONEXISTENT-999".to_string())).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_update_status_invalid() { + let state = make_state(); + let request = UpdateTicketStatusRequest { + status: "invalid".to_string(), + }; + let result = update_status(State(state), Path("FEAT-001".to_string()), Json(request)).await; + assert!(result.is_err()); + } +} diff --git a/src/rest/routes/workflow.rs b/src/rest/routes/workflow.rs new file mode 100644 index 0000000..cdfd526 --- /dev/null +++ b/src/rest/routes/workflow.rs @@ -0,0 +1,70 @@ +//! Workflow export endpoint for the REST API. +//! +//! Renders a ticket (against its issue type) into a Claude Code dynamic +//! workflow `.js`. This is the HTTP face of the same shared code path the CLI +//! and TUI use in-process (`workflow_gen::export_workflow_for_ticket`), so the +//! web UI and VS Code extension produce identical output. + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::queue::Queue; +use crate::rest::dto::WorkflowExportResponse; +use crate::rest::error::ApiError; +use crate::rest::routes::tickets::find_ticket_anywhere; +use crate::rest::state::ApiState; + +/// Export a ticket to a Claude dynamic workflow. +/// +/// Resolves the ticket (searching queue, in-progress, and completed), looks up +/// its issue type in the registry, and returns the rendered `.js` plus a +/// suggested filename. +#[utoipa::path( + operation_id = "workflow_export", + post, + path = "/api/v1/tickets/{id}/workflow-export", + tag = "Workflow", + params( + ("id" = String, Path, description = "Ticket ID (e.g., FEAT-7598)") + ), + responses( + (status = 200, description = "Generated workflow", body = WorkflowExportResponse), + (status = 404, description = "Ticket or issue type not found", body = crate::rest::error::ErrorResponse) + ) +)] +pub async fn export( + State(state): State, + Path(ticket_id): Path, +) -> Result, ApiError> { + let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let ticket = find_ticket_anywhere(&queue, &ticket_id)?; + + let registry = state.registry.read().await; + let exported = crate::workflow_gen::export_workflow_for_ticket(&ticket, ®istry, None) + .map_err(|e| ApiError::NotFound(e.to_string()))?; + + Ok(Json(exported.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn make_state() -> ApiState { + ApiState::new( + Config::default(), + PathBuf::from("/tmp/test-tickets-workflow"), + ) + } + + #[tokio::test] + async fn export_unknown_ticket_is_not_found() { + let state = make_state(); + let result = export(State(state), Path("NONEXISTENT-999".to_string())).await; + assert!(result.is_err(), "unknown ticket should error"); + } +} diff --git a/src/rest/server.rs b/src/rest/server.rs index 18ec1de..f0b503d 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -80,6 +80,9 @@ pub struct RestApiServer { shutdown_tx: Arc>>>, #[allow(dead_code)] task_handle: Arc>>>, + /// Live `ApiState` once `start()` has been called. Exposed so the + /// dashboard can read runtime info (e.g. active MCP SSE sessions). + api_state: Arc>>, } impl RestApiServer { @@ -93,9 +96,17 @@ impl RestApiServer { status: Arc::new(Mutex::new(RestApiStatus::Stopped)), shutdown_tx: Arc::new(Mutex::new(None)), task_handle: Arc::new(Mutex::new(None)), + api_state: Arc::new(Mutex::new(None)), } } + /// Returns a clone of the live `ApiState` if the server has been started. + /// `ApiState` is `Clone` with internal `Arc`s, so the clone shares the + /// same `mcp_sessions` map as the running server. + pub fn api_state(&self) -> Option { + self.api_state.lock().unwrap().clone() + } + /// Get current server status pub fn status(&self) -> RestApiStatus { self.status.lock().unwrap().clone() @@ -131,10 +142,13 @@ impl RestApiServer { *self.shutdown_tx.lock().unwrap() = Some(shutdown_tx); let state = ApiState::new(self.config.clone(), self.config.tickets_path()); + // Expose the live state to the dashboard before handing it to the router. + *self.api_state.lock().unwrap() = Some(state.clone()); let router = build_router(state); let port = self.port; let status = self.status.clone(); let tickets_path = self.tickets_path.clone(); + let api_state_handle = self.api_state.clone(); *status.lock().unwrap() = RestApiStatus::Starting; @@ -168,6 +182,9 @@ impl RestApiServer { } *status.lock().unwrap() = RestApiStatus::Stopped; + // Drop the api_state once the server task exits so the dashboard + // reflects "no server" rather than a stale snapshot. + *api_state_handle.lock().unwrap() = None; }); *self.task_handle.lock().unwrap() = Some(handle); @@ -236,6 +253,25 @@ mod tests { let server = RestApiServer::new(config, 7008); assert_eq!(server.status(), RestApiStatus::Stopped); assert!(!server.is_running()); + assert!( + server.api_state().is_none(), + "api_state should be unset before start()" + ); + } + + #[tokio::test] + async fn test_start_exposes_api_state() { + // Use port 0 to let the OS pick a free port (avoids collisions). + let config = Config::default(); + let server = RestApiServer::new(config, 0); + server.start().expect("start should succeed"); + // Give the spawned task a brief moment to set status to Running and + // populate api_state. (start() already sleeps 50ms internally.) + assert!( + server.api_state().is_some(), + "api_state should be populated after start()" + ); + server.stop(); } #[test] diff --git a/src/rest/state.rs b/src/rest/state.rs index ed7ba21..5cde266 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -9,7 +9,7 @@ use tokio::sync::{Mutex, RwLock}; use crate::api::kanban_sync::KanbanBidirectionalSync; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; -use crate::startup::templates::{ensure_schemas, init_default_templates}; +use crate::startup::templates::load_registry; /// Shared state for the REST API #[derive(Clone)] @@ -36,50 +36,9 @@ impl ApiState { /// 2. If empty, initialize default templates from embedded files /// 3. Fallback to embedded builtins if filesystem loading fails pub fn new(config: Config, tickets_path: PathBuf) -> Self { - let mut registry = IssueTypeRegistry::new(); - let templates_path = tickets_path.join("templates"); - - // Ensure schema files exist (runs every time, even if templates exist) - if let Err(e) = ensure_schemas(&tickets_path) { - tracing::warn!("Failed to ensure schema files: {}", e); - } - - // Try to load from templates directory first - match registry.load_from_templates_dir(&templates_path) { - Ok(()) if registry.type_count() > 0 => { - tracing::info!( - "Loaded {} issue types from templates directory", - registry.type_count() - ); - } - Ok(()) => { - // Templates directory empty or doesn't exist - initialize defaults - tracing::info!("Templates directory empty, initializing defaults..."); - if let Err(e) = init_default_templates(&templates_path) { - tracing::warn!("Failed to initialize default templates: {}", e); - } else { - // Try loading again after initialization - if let Err(e) = registry.load_from_templates_dir(&templates_path) { - tracing::warn!("Failed to load initialized templates: {}", e); - } - } - - // If still empty, fallback to embedded builtins - if registry.type_count() == 0 { - tracing::info!("Falling back to embedded builtin types"); - if let Err(e) = registry.load_builtins() { - tracing::warn!("Failed to load builtin issue types: {}", e); - } - } - } - Err(e) => { - tracing::warn!("Failed to load from templates directory: {}", e); - // Fallback to embedded builtins - if let Err(e) = registry.load_builtins() { - tracing::warn!("Failed to load builtin issue types: {}", e); - } - } - } + // Shared loader — keeps the API's issue-type resolution identical to the + // CLI/TUI so `workflow export` produces the same output on every surface. + let registry = load_registry(&tickets_path); let config_arc = Arc::new(config); let kanban_sync = { diff --git a/src/rest/web_ui.rs b/src/rest/web_ui.rs new file mode 100644 index 0000000..ba32ad3 --- /dev/null +++ b/src/rest/web_ui.rs @@ -0,0 +1,169 @@ +//! Embedded web UI served via rust-embed. +//! +//! Gated behind the `embed-ui` feature flag. + +use axum::http::{header, StatusCode, Uri}; +use axum::response::{Html, IntoResponse, Response}; +use rust_embed::Embed; + +#[derive(Embed)] +#[folder = "ui/dist"] +struct UiAssets; + +/// Sentinel substring written into `ui/dist/index.html` by `build.rs` when the +/// SPA hasn't been built. The runtime detector uses this to distinguish a real +/// Vite build from a placeholder so the TUI can give the user an actionable +/// message instead of opening a blank page. +pub const PLACEHOLDER_MARKER: &str = "operator:placeholder"; + +/// Whether the embedded SPA is usable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbeddedUiState { + /// A real built SPA is embedded. + Ready, + /// The build.rs placeholder is embedded — `ui/dist` wasn't built before + /// the cargo build. + Placeholder, + /// No SPA assets at all (should be unreachable when this module compiles). + Missing, +} + +/// Inspect the embedded assets and report whether a real SPA is available. +pub fn embedded_ui_state() -> EmbeddedUiState { + let Some(file) = UiAssets::get("index.html") else { + return EmbeddedUiState::Missing; + }; + let body = std::str::from_utf8(&file.data).unwrap_or(""); + if body.contains(PLACEHOLDER_MARKER) { + EmbeddedUiState::Placeholder + } else { + EmbeddedUiState::Ready + } +} + +/// Axum fallback handler that serves embedded SPA assets. +/// +/// Priority: exact file match → index.html (SPA client-side routing). +pub async fn spa_handler(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + + if let Some(file) = UiAssets::get(path) { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime.as_ref().to_string()), + (header::CACHE_CONTROL, cache_policy(path)), + ], + file.data.into_owned(), + ) + .into_response() + } else { + match UiAssets::get("index.html") { + Some(index) => Html(String::from_utf8_lossy(&index.data).to_string()).into_response(), + None => (StatusCode::NOT_FOUND, "UI not built").into_response(), + } + } +} + +fn cache_policy(path: &str) -> String { + // Vite produces hashed filenames like `assets/index-abc123.js` + if path.starts_with("assets/") { + "public, max-age=31536000, immutable".into() + } else { + "no-cache".into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + const TEN_MB: usize = 10_485_760; + const FIFTEEN_MB: usize = 15_728_640; + + #[test] + fn test_embedded_assets_under_10mb_gzipped() { + let mut compressed_total: usize = 0; + let mut uncompressed_total: usize = 0; + + for path in UiAssets::iter() { + if let Some(file) = UiAssets::get(&path) { + uncompressed_total += file.data.len(); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&file.data).expect("gzip write"); + let compressed = encoder.finish().expect("gzip finish"); + compressed_total += compressed.len(); + } + } + + assert!( + compressed_total < TEN_MB, + "Embedded UI assets: {compressed_total}B ({:.1}MB) gzipped — exceeds 10MB budget \ + (uncompressed: {uncompressed_total}B / {:.1}MB)", + compressed_total as f64 / 1_048_576.0, + uncompressed_total as f64 / 1_048_576.0, + ); + } + + #[test] + fn test_embedded_assets_under_15mb_uncompressed() { + let total: usize = UiAssets::iter() + .filter_map(|path| UiAssets::get(&path)) + .map(|file| file.data.len()) + .sum(); + + assert!( + total < FIFTEEN_MB, + "Embedded UI assets: {total}B ({:.1}MB) uncompressed — exceeds 15MB budget", + total as f64 / 1_048_576.0, + ); + } + + #[test] + fn test_index_html_exists() { + assert!( + UiAssets::get("index.html").is_some(), + "ui/dist/index.html must exist in embedded assets" + ); + } + + #[test] + fn test_spa_fallback_returns_index_for_unknown_paths() { + let index_content = UiAssets::get("index.html").expect("index.html must exist"); + let index_str = String::from_utf8_lossy(&index_content.data); + assert!( + index_str.contains("") + || index_str.contains("") + || index_str.contains(" usize { self.tracked_prs.read().await.len() } /// Check if a specific PR is being tracked - #[allow(dead_code)] // Utility method for future use + #[allow(dead_code)] pub async fn is_tracking(&self, repo_info: &RepoInfo, pr_number: i64) -> bool { let key = Self::pr_key(repo_info, pr_number); self.tracked_prs.read().await.contains_key(&key) diff --git a/src/setup.rs b/src/setup.rs index 3dca7fb..a3a453d 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -8,7 +8,6 @@ use std::fs; use std::path::PathBuf; use crate::agents::{generate_status_script, generate_tmux_conf}; -use crate::backstage::scaffold::{BackstageScaffold, ScaffoldOptions}; use crate::config::{CollectionPreset, Config}; use crate::templates::TemplateType; @@ -20,8 +19,6 @@ pub const COMMON_OPTIONAL_FIELDS: &[&str] = &["priority", "points", "user_story" pub struct SetupOptions { /// Collection preset to use pub preset: CollectionPreset, - /// Whether to enable backstage - pub backstage_enabled: bool, /// Overwrite existing files pub force: bool, /// Optional fields to include (propagated to all types) @@ -146,25 +143,12 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu config.templates.collection = issue_types; } - // Configure backstage if enabled - config.backstage.enabled = options.backstage_enabled; - // Configure git worktree preference config.git.use_worktrees = options.use_worktrees; // Generate tmux config generate_tmux_config(config)?; - // Generate backstage scaffold if enabled - if options.backstage_enabled { - let backstage_path = config.backstage_path(); - if !BackstageScaffold::exists(&backstage_path) || options.force { - let scaffold_options = ScaffoldOptions::from_config(config); - let scaffold = BackstageScaffold::new(backstage_path, scaffold_options); - scaffold.generate()?; - } - } - // Discover projects (git repos and/or LLM marker files) let discovered = crate::projects::discover_projects_with_git(&config.projects_path()); config.projects = discovered.iter().map(|p| p.name.clone()).collect(); @@ -338,7 +322,6 @@ mod tests { let options = SetupOptions::default(); // CollectionPreset defaults to DevKanban via its #[default] attribute assert_eq!(options.preset, CollectionPreset::DevKanban); - assert!(!options.backstage_enabled); assert!(!options.force); assert!(options.working_dir.is_none()); assert!(options.kanban_provider.is_none()); @@ -460,24 +443,6 @@ mod tests { assert_ne!(content, "existing content"); } - #[test] - fn test_initialize_workspace_with_backstage() { - let temp_dir = TempDir::new().unwrap(); - let tickets_path = temp_dir.path().join(".tickets"); - - let mut config = Config::default(); - config.paths.tickets = tickets_path.to_string_lossy().to_string(); - config.paths.state = tickets_path.join("operator").to_string_lossy().to_string(); - - let options = SetupOptions { - backstage_enabled: true, - ..Default::default() - }; - initialize_workspace(&mut config, &options).unwrap(); - - assert!(config.backstage.enabled); - } - #[test] fn test_initialize_workspace_discovers_projects() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 406ebc8..18837ba 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -24,7 +24,7 @@ pub mod templates; /// Information about a setup wizard step for documentation purposes. -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs #[derive(Debug, Clone)] pub struct SetupStepInfo { /// Display name of the step (e.g., "Welcome") @@ -45,7 +45,7 @@ pub struct SetupStepInfo { /// selection, and finally confirmation. /// /// When adding new steps to the setup wizard, add corresponding entries here. -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs pub static SETUP_STEPS: &[SetupStepInfo] = &[ // ── Tier 0: Config / Welcome ───────────────────────────────────────────── SetupStepInfo { diff --git a/src/startup/templates.rs b/src/startup/templates.rs index 2448e20..b45b133 100644 --- a/src/startup/templates.rs +++ b/src/startup/templates.rs @@ -11,6 +11,57 @@ use tracing::info; use crate::collections::{ get_embedded_collection, EmbeddedCollection, EMBEDDED_COLLECTIONS, EMBEDDED_SCHEMAS, }; +use crate::issuetypes::IssueTypeRegistry; + +/// Build an `IssueTypeRegistry` for a workspace using the canonical loading +/// priority, so every surface (REST API, CLI, TUI) resolves the same issue +/// types from the same place: +/// +/// 1. Load from `.tickets/templates/` (collection-scoped structure). +/// 2. If empty, initialize default templates from embedded files, then reload. +/// 3. Fall back to embedded builtins if filesystem loading fails or yields none. +pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { + let mut registry = IssueTypeRegistry::new(); + let templates_path = tickets_path.join("templates"); + + // Ensure schema files exist (runs every time, even if templates exist). + if let Err(e) = ensure_schemas(tickets_path) { + tracing::warn!("Failed to ensure schema files: {}", e); + } + + match registry.load_from_templates_dir(&templates_path) { + Ok(()) if registry.type_count() > 0 => { + info!( + "Loaded {} issue types from templates directory", + registry.type_count() + ); + } + Ok(()) => { + // Templates directory empty or absent — initialize defaults. + info!("Templates directory empty, initializing defaults..."); + if let Err(e) = init_default_templates(&templates_path) { + tracing::warn!("Failed to initialize default templates: {}", e); + } else if let Err(e) = registry.load_from_templates_dir(&templates_path) { + tracing::warn!("Failed to load initialized templates: {}", e); + } + + if registry.type_count() == 0 { + info!("Falling back to embedded builtin types"); + if let Err(e) = registry.load_builtins() { + tracing::warn!("Failed to load builtin issue types: {}", e); + } + } + } + Err(e) => { + tracing::warn!("Failed to load from templates directory: {}", e); + if let Err(e) = registry.load_builtins() { + tracing::warn!("Failed to load builtin issue types: {}", e); + } + } + } + + registry +} /// Initialize the templates directory with default collections /// @@ -215,16 +266,16 @@ mod tests { assert!(templates_path.join("dev_kanban").exists()); assert!(templates_path.join("devops_kanban").exists()); assert!(templates_path.join("operator").exists()); - assert!(templates_path.join("backstage_full").exists()); - - // backstage_full should have all 8 issuetypes - assert!(templates_path.join("backstage_full/TASK.json").exists()); - assert!(templates_path.join("backstage_full/FEAT.json").exists()); - assert!(templates_path.join("backstage_full/FIX.json").exists()); - assert!(templates_path.join("backstage_full/SPIKE.json").exists()); - assert!(templates_path.join("backstage_full/INV.json").exists()); - assert!(templates_path.join("backstage_full/ASSESS.json").exists()); - assert!(templates_path.join("backstage_full/SYNC.json").exists()); - assert!(templates_path.join("backstage_full/INIT.json").exists()); + assert!(templates_path.join("full").exists()); + + // full should have all 8 issuetypes + assert!(templates_path.join("full/TASK.json").exists()); + assert!(templates_path.join("full/FEAT.json").exists()); + assert!(templates_path.join("full/FIX.json").exists()); + assert!(templates_path.join("full/SPIKE.json").exists()); + assert!(templates_path.join("full/INV.json").exists()); + assert!(templates_path.join("full/ASSESS.json").exists()); + assert!(templates_path.join("full/SYNC.json").exists()); + assert!(templates_path.join("full/INIT.json").exists()); } } diff --git a/src/state.rs b/src/state.rs index fd4dba8..4588c12 100644 --- a/src/state.rs +++ b/src/state.rs @@ -334,6 +334,60 @@ impl State { Ok(id) } + /// Add an agent with a pre-allocated ID and full launch options. + /// + /// Use this when the agent ID must be known before state registration + /// (e.g., for injecting the ID into environment variables at launch time). + #[allow(clippy::too_many_arguments)] // mirrors add_agent_with_full_options + explicit id + pub fn add_agent_with_explicit_id( + &mut self, + id: String, + ticket_id: String, + ticket_type: String, + project: String, + paired: bool, + llm_tool: Option, + launch_mode: Option, + llm_model: Option, + ) -> Result { + let now = Utc::now(); + + self.agents.push(AgentState { + id: id.clone(), + ticket_id, + ticket_type, + project, + status: "running".to_string(), + started_at: now, + last_activity: now, + last_message: None, + paired, + session_name: None, + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, + session_pane_ref: None, + content_hash: None, + current_step: None, + step_started_at: None, + last_content_change: Some(now), + pr_url: None, + pr_number: None, + github_repo: None, + pr_status: None, + completed_steps: Vec::new(), + llm_tool, + llm_model, + launch_mode, + review_state: None, + dev_server_pid: None, + worktree_path: None, + }); + + self.save()?; + Ok(id) + } + pub fn update_agent_status( &mut self, agent_id: &str, @@ -391,10 +445,13 @@ impl State { .collect() } - pub fn is_project_busy(&self, project: &str) -> bool { + pub fn project_agent_count(&self, project: &str) -> usize { self.agents .iter() - .any(|a| a.project == project && a.status == "running") + .filter(|a| { + a.project == project && (a.status == "running" || a.status == "awaiting_input") + }) + .count() } /// Update the terminal session name for an agent @@ -1235,7 +1292,7 @@ mod tests { } #[test] - fn test_is_project_busy_running() { + fn test_project_agent_count_running() { let temp_dir = TempDir::new().unwrap(); let config = test_config(&temp_dir); let mut state = State::load(&config).unwrap(); @@ -1249,13 +1306,12 @@ mod tests { ) .unwrap(); - // Agent starts with status "running" - assert!(state.is_project_busy("test-project")); - assert!(!state.is_project_busy("other-project")); + assert_eq!(state.project_agent_count("test-project"), 1); + assert_eq!(state.project_agent_count("other-project"), 0); } #[test] - fn test_is_project_busy_awaiting_input() { + fn test_project_agent_count_includes_awaiting_input() { let temp_dir = TempDir::new().unwrap(); let config = test_config(&temp_dir); let mut state = State::load(&config).unwrap(); @@ -1273,8 +1329,63 @@ mod tests { .update_agent_status(&id, "awaiting_input", None) .unwrap(); - // is_project_busy only checks for "running" status - assert!(!state.is_project_busy("test-project")); + assert_eq!(state.project_agent_count("test-project"), 1); + } + + #[test] + fn test_project_agent_count_multiple() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + state + .add_agent( + "FEAT-001".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "FEAT-002".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "FEAT-003".to_string(), + "FEAT".to_string(), + "other-project".to_string(), + false, + ) + .unwrap(); + + assert_eq!(state.project_agent_count("test-project"), 2); + assert_eq!(state.project_agent_count("other-project"), 1); + assert_eq!(state.project_agent_count("nonexistent"), 0); + } + + #[test] + fn test_project_agent_count_excludes_completed() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let id = state + .add_agent( + "FEAT-001".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state.update_agent_status(&id, "completed", None).unwrap(); + + assert_eq!(state.project_agent_count("test-project"), 0); } // ─── Step Completion Tests ─────────────────────────────────────────────────── @@ -1715,4 +1826,36 @@ mod tests { assert_eq!(group.pending_launches[0].variant_key, "1"); assert_eq!(group.agent_variant_keys.get("agent-A").unwrap(), "0"); } + + #[test] + fn test_add_agent_with_explicit_id_uses_provided_id() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let explicit_id = "my-custom-agent-id-12345".to_string(); + let result = state.add_agent_with_explicit_id( + explicit_id.clone(), + "FEAT-001".to_string(), + "FEAT".to_string(), + "testproject".to_string(), + false, + Some("claude".to_string()), + Some("default".to_string()), + Some("sonnet".to_string()), + ); + + assert!(result.is_ok()); + let returned_id = result.unwrap(); + assert_eq!(returned_id, explicit_id); + + let agent = state.agents.iter().find(|a| a.id == explicit_id); + assert!(agent.is_some()); + let agent = agent.unwrap(); + assert_eq!(agent.ticket_id, "FEAT-001"); + assert_eq!(agent.project, "testproject"); + assert_eq!(agent.llm_tool, Some("claude".to_string())); + assert_eq!(agent.llm_model, Some("sonnet".to_string())); + assert_eq!(agent.status, "running"); + } } diff --git a/src/steps/manager.rs b/src/steps/manager.rs index 579c5ea..916a453 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -156,16 +156,17 @@ impl StepManager { self.render_prompt(&step.prompt, ticket, pr_config) } - /// Render a prompt template with ticket data - fn render_prompt( - &self, - template: &str, + /// Build the Handlebars interpolation context for a ticket. + /// + /// This is the single source of truth for the variable surface available + /// to step prompts (`id`, `ticket_type`, `project`, `summary`, `priority`, + /// `step`, optional PR vars, and `steps..*` step outputs). Other + /// renderers (e.g. workflow export) reuse this so prompts interpolate + /// identically to how they would at launch time. + pub fn build_ticket_context( ticket: &Ticket, pr_config: Option<&PrConfig>, - ) -> Result { - let mut hbs = Handlebars::new(); - hbs.set_strict_mode(false); - + ) -> serde_json::Value { let mut data = serde_json::Map::new(); data.insert("id".to_string(), serde_json::json!(ticket.id)); data.insert( @@ -199,7 +200,22 @@ impl StepManager { data.insert("steps".to_string(), serde_json::Value::Object(steps_data)); } - hbs.render_template(template, &serde_json::Value::Object(data)) + serde_json::Value::Object(data) + } + + /// Render a prompt template with ticket data + fn render_prompt( + &self, + template: &str, + ticket: &Ticket, + pr_config: Option<&PrConfig>, + ) -> Result { + let mut hbs = Handlebars::new(); + hbs.set_strict_mode(false); + + let data = Self::build_ticket_context(ticket, pr_config); + + hbs.render_template(template, &data) .context("Failed to render step prompt") } diff --git a/src/backstage/analyzer.rs b/src/taxonomy/analyzer.rs similarity index 100% rename from src/backstage/analyzer.rs rename to src/taxonomy/analyzer.rs diff --git a/src/backstage/taxonomy.rs b/src/taxonomy/mod.rs similarity index 92% rename from src/backstage/taxonomy.rs rename to src/taxonomy/mod.rs index d297880..a6e1077 100644 --- a/src/backstage/taxonomy.rs +++ b/src/taxonomy/mod.rs @@ -6,20 +6,19 @@ //! - Compile-time validation of taxonomy structure //! - Zero runtime I/O for taxonomy access //! -//! Note: Types are marked #[`allow(dead_code)`] during Milestone 1 as they will -//! be used in subsequent milestones (ASSESS issue type, project analysis, etc.) +//! Types are used by the docs generator, project analyzer, and Kind detection. + +pub mod analyzer; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// The complete project taxonomy, loaded from taxonomy.toml -#[allow(dead_code)] static TAXONOMY: std::sync::LazyLock = std::sync::LazyLock::new(|| { toml::from_str(include_str!("taxonomy.toml")).expect("taxonomy.toml must be valid TOML") }); /// Kind tier classification -#[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum KindTier { @@ -30,7 +29,6 @@ pub enum KindTier { Noncurrent, } -#[allow(dead_code)] impl KindTier { /// Returns all tiers in order pub fn all() -> &'static [KindTier] { @@ -81,7 +79,6 @@ impl std::fmt::Display for KindTier { } /// Metadata about the taxonomy -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaxonomyMeta { pub version: String, @@ -89,7 +86,6 @@ pub struct TaxonomyMeta { } /// A tier grouping for Kinds -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tier { pub id: u8, @@ -119,7 +115,6 @@ pub struct Tier { pub assess_testing: bool, } -#[allow(dead_code)] impl Tier { /// Returns the tier enum variant pub fn tier(&self) -> Option { @@ -166,7 +161,6 @@ impl Tier { } /// A project Kind definition -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Kind { pub id: u8, @@ -177,7 +171,7 @@ pub struct Kind { pub stakeholder: String, pub output: String, pub file_patterns: Vec, - pub backstage_type: String, + pub catalog_type: String, /// Icon identifier (optional, falls back to tier icon) #[serde(default)] pub icon: Option, @@ -186,7 +180,6 @@ pub struct Kind { pub display_order: Option, } -#[allow(dead_code)] impl Kind { /// Returns the tier enum variant for this Kind pub fn tier_enum(&self) -> Option { @@ -215,7 +208,6 @@ impl Kind { } /// The complete taxonomy structure -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Taxonomy { pub meta: TaxonomyMeta, @@ -223,7 +215,6 @@ pub struct Taxonomy { pub kinds: Vec, } -#[allow(dead_code)] impl Taxonomy { /// Get the global taxonomy instance (loaded at first access) pub fn load() -> &'static Taxonomy { @@ -294,7 +285,7 @@ impl Taxonomy { .filter_map(|(key, count)| self.kind_by_key(key).map(|k| (k, count))) .collect(); - results.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by count descending + results.sort_by_key(|b| std::cmp::Reverse(b.1)); // Sort by count descending results } @@ -390,7 +381,7 @@ mod tests { } #[test] - fn test_all_kinds_have_backstage_type() { + fn test_all_kinds_have_catalog_type() { let t = Taxonomy::load(); let valid_types = [ "service", @@ -405,10 +396,10 @@ mod tests { ]; for kind in &t.kinds { assert!( - valid_types.contains(&kind.backstage_type.as_str()), - "Kind {} has invalid backstage_type: {}. Valid types: {:?}", + valid_types.contains(&kind.catalog_type.as_str()), + "Kind {} has invalid catalog_type: {}. Valid types: {:?}", kind.key, - kind.backstage_type, + kind.catalog_type, valid_types ); } @@ -433,7 +424,6 @@ mod tests { #[test] fn test_kinds_by_tier() { let t = Taxonomy::load(); - // Verify each tier returns kinds and they all reference the correct tier for tier_enum in KindTier::all() { let kinds = t.kinds_by_tier(*tier_enum); assert!(!kinds.is_empty(), "{tier_enum} tier should have kinds"); @@ -467,7 +457,6 @@ mod tests { detected.is_some(), "Should detect a kind for Rust service files" ); - // microservice should match src/main.rs and Dockerfile let (kind, _) = detected.unwrap(); assert_eq!(kind.key, "microservice"); } @@ -497,7 +486,6 @@ mod tests { fn test_tier_display_order_fallback() { let t = Taxonomy::load(); for tier in &t.tiers { - // display_order should fall back to id if not set let order = tier.display_order(); assert!( order >= 1, @@ -512,7 +500,6 @@ mod tests { fn test_tier_sidebar_label_fallback() { let t = Taxonomy::load(); for tier in &t.tiers { - // sidebar_label should fall back to name if not set let label = tier.sidebar_label(); assert!( !label.is_empty(), @@ -527,7 +514,6 @@ mod tests { let t = Taxonomy::load(); let ordered = t.tiers_by_display_order(); assert!(!ordered.is_empty(), "Should have tiers"); - // Verify tiers are sorted by display_order for i in 1..ordered.len() { assert!( ordered[i - 1].display_order() <= ordered[i].display_order(), @@ -539,10 +525,8 @@ mod tests { #[test] fn test_kinds_by_tier_ordered() { let t = Taxonomy::load(); - // Test ordering for each tier for tier_enum in KindTier::all() { let kinds = t.kinds_by_tier_ordered(*tier_enum); - // Kinds should be sorted by display_order (falls back to id) for i in 1..kinds.len() { assert!( kinds[i - 1].display_order() <= kinds[i].display_order(), @@ -556,7 +540,6 @@ mod tests { fn test_kind_display_order_fallback() { let t = Taxonomy::load(); for kind in &t.kinds { - // display_order should fall back to id if not set let order = kind.display_order(); assert!( order >= 1, @@ -571,31 +554,26 @@ mod tests { fn test_tier_assessment_scope_flags() { let t = Taxonomy::load(); - // Foundation: no assessments let foundation = t.tier_def(KindTier::Foundation).unwrap(); assert!(!foundation.should_assess_frameworks()); assert!(!foundation.should_assess_databases()); assert!(!foundation.should_assess_testing()); - // Standards: frameworks and testing only let standards = t.tier_def(KindTier::Standards).unwrap(); assert!(standards.should_assess_frameworks()); assert!(!standards.should_assess_databases()); assert!(standards.should_assess_testing()); - // Engines: all assessments let engines = t.tier_def(KindTier::Engines).unwrap(); assert!(engines.should_assess_frameworks()); assert!(engines.should_assess_databases()); assert!(engines.should_assess_testing()); - // Ecosystem: all assessments let ecosystem = t.tier_def(KindTier::Ecosystem).unwrap(); assert!(ecosystem.should_assess_frameworks()); assert!(ecosystem.should_assess_databases()); assert!(ecosystem.should_assess_testing()); - // Noncurrent: no assessments let noncurrent = t.tier_def(KindTier::Noncurrent).unwrap(); assert!(!noncurrent.should_assess_frameworks()); assert!(!noncurrent.should_assess_databases()); @@ -622,7 +600,7 @@ mod tests { let kind = kind.unwrap(); assert_eq!(kind.tier, "noncurrent"); - assert_eq!(kind.backstage_type, "resource"); + assert_eq!(kind.catalog_type, "resource"); assert!(kind.matches_pattern("fixtures/users.json")); assert!(kind.matches_pattern("testdata/sample.csv")); assert!(kind.matches_pattern("db/seeds/users.sql")); diff --git a/src/backstage/taxonomy.toml b/src/taxonomy/taxonomy.toml similarity index 94% rename from src/backstage/taxonomy.toml rename to src/taxonomy/taxonomy.toml index f35b1ef..0fdace8 100644 --- a/src/backstage/taxonomy.toml +++ b/src/taxonomy/taxonomy.toml @@ -3,7 +3,7 @@ # This file defines the 24-Kind project taxonomy used by: # - Rust code (parsed at compile time) # - Documentation generation -# - Backstage catalog schema extension +# - project classification schema extension # - Project assessment and Kind detection # # To add a new Kind: @@ -14,7 +14,7 @@ [meta] version = "1.0.0" -description = "Operator project taxonomy for Backstage catalog" +description = "Operator project taxonomy for project classification" # Tiers organize Kinds into logical groups # Each tier has an ID range for its Kinds @@ -114,7 +114,7 @@ file_patterns = [ "serverless.yml", "terraform.tfstate", ] -backstage_type = "resource" +catalog_type = "resource" [[kinds]] id = 2 @@ -135,7 +135,7 @@ file_patterns = [ ".vault/*", "vault-*.hcl", ] -backstage_type = "resource" +catalog_type = "resource" [[kinds]] id = 3 @@ -156,7 +156,7 @@ file_patterns = [ "feature-flags.json", "launchdarkly*.yaml", ] -backstage_type = "resource" +catalog_type = "resource" [[kinds]] id = 4 @@ -177,7 +177,7 @@ file_patterns = [ "CLAUDE.md", "CONTRIBUTING.md", ] -backstage_type = "system" +catalog_type = "system" # ============================================================================= # STANDARDS TIER (5-10) @@ -202,7 +202,7 @@ file_patterns = [ "figma-tokens.json", "style-dictionary.config.*", ] -backstage_type = "library" +catalog_type = "library" [[kinds]] id = 6 @@ -222,7 +222,7 @@ file_patterns = [ "setup.py", "pyproject.toml", ] -backstage_type = "library" +catalog_type = "library" [[kinds]] id = 7 @@ -246,7 +246,7 @@ file_patterns = [ "buf.yaml", "buf.gen.yaml", ] -backstage_type = "api" +catalog_type = "api" [[kinds]] id = 8 @@ -266,7 +266,7 @@ file_patterns = [ "blueprint/*", ".scaffold/*", ] -backstage_type = "template" +catalog_type = "template" [[kinds]] id = 9 @@ -288,7 +288,7 @@ file_patterns = [ "bandit.yaml", "safety/*", ] -backstage_type = "tool" +catalog_type = "tool" [[kinds]] id = 10 @@ -309,7 +309,7 @@ file_patterns = [ "attestations/*", "controls/*.yaml", ] -backstage_type = "documentation" +catalog_type = "documentation" # ============================================================================= # ENGINES TIER (11-16) @@ -338,7 +338,7 @@ file_patterns = [ "dvc.yaml", "dvc.lock", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 12 @@ -359,7 +359,7 @@ file_patterns = [ "dagster.yaml", "fivetran/*", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 13 @@ -381,7 +381,7 @@ file_patterns = [ "Dockerfile", "docker-compose.yml", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 14 @@ -403,7 +403,7 @@ file_patterns = [ "routes/*", "api/*.yaml", ] -backstage_type = "api" +catalog_type = "api" [[kinds]] id = 15 @@ -426,7 +426,7 @@ file_patterns = [ "angular.json", "expo/*", ] -backstage_type = "website" +catalog_type = "website" [[kinds]] id = 16 @@ -444,7 +444,7 @@ file_patterns = [ "retool/*", "metabase/*", ] -backstage_type = "service" +catalog_type = "service" # ============================================================================= # ECOSYSTEM TIER (17-21) @@ -471,7 +471,7 @@ file_patterns = [ "Makefile", "Taskfile.yml", ] -backstage_type = "tool" +catalog_type = "tool" [[kinds]] id = 18 @@ -493,7 +493,7 @@ file_patterns = [ "k6/*", "locust/*", ] -backstage_type = "tool" +catalog_type = "tool" [[kinds]] id = 19 @@ -515,7 +515,7 @@ file_patterns = [ "conf.py", # Sphinx "book.toml", # mdBook ] -backstage_type = "website" +catalog_type = "website" [[kinds]] id = 20 @@ -534,7 +534,7 @@ file_patterns = [ "sops/*", "procedures/*", ] -backstage_type = "documentation" +catalog_type = "documentation" [[kinds]] id = 21 @@ -553,7 +553,7 @@ file_patterns = [ "*.sh", "Justfile", ] -backstage_type = "tool" +catalog_type = "tool" # ============================================================================= # NONCURRENT TIER (22-25) @@ -576,7 +576,7 @@ file_patterns = [ "quickstart/*", "getting-started/*", ] -backstage_type = "documentation" +catalog_type = "documentation" [[kinds]] id = 23 @@ -595,7 +595,7 @@ file_patterns = [ "scratch/*", "playground/*", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 24 @@ -614,7 +614,7 @@ file_patterns = [ "legacy/*", "deprecated/*", ] -backstage_type = "library" +catalog_type = "library" [[kinds]] id = 25 @@ -638,4 +638,4 @@ file_patterns = [ "*.seed.sql", "db/seeds/*", ] -backstage_type = "resource" +catalog_type = "resource" diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 13b0a02..a85cd25 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -130,7 +130,7 @@ impl TemplateType { TemplateType::Investigation => "Investigation", TemplateType::Assess => "Project Assessment", TemplateType::Sync => "Catalog Sync", - TemplateType::Init => "Backstage Init", + TemplateType::Init => "Workspace Init", } } @@ -143,38 +143,38 @@ impl TemplateType { TemplateType::Spike => "Research or exploration (paired mode)", TemplateType::Investigation => "Incident investigation (paired mode)", TemplateType::Assess => "Analyze project and generate catalog-info.yaml", - TemplateType::Sync => "Refresh Backstage catalog entries", - TemplateType::Init => "Initialize Backstage in workspace (paired mode)", + TemplateType::Sync => "Refresh catalog entries", + TemplateType::Init => "Initialize workspace (paired mode)", } } /// Returns the embedded markdown template content - /// Source of truth: `src/collections/backstage_full`/ + /// Source of truth: `src/collections/full/` pub fn template_content(&self) -> &'static str { match self { - TemplateType::Feature => include_str!("../collections/backstage_full/FEAT.md"), - TemplateType::Fix => include_str!("../collections/backstage_full/FIX.md"), - TemplateType::Task => include_str!("../collections/backstage_full/TASK.md"), - TemplateType::Spike => include_str!("../collections/backstage_full/SPIKE.md"), - TemplateType::Investigation => include_str!("../collections/backstage_full/INV.md"), - TemplateType::Assess => include_str!("../collections/backstage_full/ASSESS.md"), - TemplateType::Sync => include_str!("../collections/backstage_full/SYNC.md"), - TemplateType::Init => include_str!("../collections/backstage_full/INIT.md"), + TemplateType::Feature => include_str!("../collections/full/FEAT.md"), + TemplateType::Fix => include_str!("../collections/full/FIX.md"), + TemplateType::Task => include_str!("../collections/full/TASK.md"), + TemplateType::Spike => include_str!("../collections/full/SPIKE.md"), + TemplateType::Investigation => include_str!("../collections/full/INV.md"), + TemplateType::Assess => include_str!("../collections/full/ASSESS.md"), + TemplateType::Sync => include_str!("../collections/full/SYNC.md"), + TemplateType::Init => include_str!("../collections/full/INIT.md"), } } /// Returns the embedded JSON schema content - /// Source of truth: `src/collections/backstage_full`/ + /// Source of truth: `src/collections/full/` pub fn schema(&self) -> &'static str { match self { - TemplateType::Feature => include_str!("../collections/backstage_full/FEAT.json"), - TemplateType::Fix => include_str!("../collections/backstage_full/FIX.json"), - TemplateType::Task => include_str!("../collections/backstage_full/TASK.json"), - TemplateType::Spike => include_str!("../collections/backstage_full/SPIKE.json"), - TemplateType::Investigation => include_str!("../collections/backstage_full/INV.json"), - TemplateType::Assess => include_str!("../collections/backstage_full/ASSESS.json"), - TemplateType::Sync => include_str!("../collections/backstage_full/SYNC.json"), - TemplateType::Init => include_str!("../collections/backstage_full/INIT.json"), + TemplateType::Feature => include_str!("../collections/full/FEAT.json"), + TemplateType::Fix => include_str!("../collections/full/FIX.json"), + TemplateType::Task => include_str!("../collections/full/TASK.json"), + TemplateType::Spike => include_str!("../collections/full/SPIKE.json"), + TemplateType::Investigation => include_str!("../collections/full/INV.json"), + TemplateType::Assess => include_str!("../collections/full/ASSESS.json"), + TemplateType::Sync => include_str!("../collections/full/SYNC.json"), + TemplateType::Init => include_str!("../collections/full/INIT.json"), } } diff --git a/src/types/llm_stats.rs b/src/types/llm_stats.rs index 4dbd604..b76c669 100644 --- a/src/types/llm_stats.rs +++ b/src/types/llm_stats.rs @@ -198,12 +198,8 @@ impl ProjectLlmStats { /// Get average time per ticket (in seconds) pub fn avg_time_per_ticket(&self) -> Option { let total_tickets = self.total_tickets(); - if total_tickets > 0 { - let total_time: u64 = self.tool_usage.values().map(|u| u.total_time_secs).sum(); - Some(total_time / total_tickets) - } else { - None - } + let total_time: u64 = self.tool_usage.values().map(|u| u.total_time_secs).sum(); + total_time.checked_div(total_tickets) } } diff --git a/src/types/project.rs b/src/types/project.rs index 23b62a1..71a0244 100644 --- a/src/types/project.rs +++ b/src/types/project.rs @@ -48,7 +48,7 @@ pub struct Project { #[ts(type = "string | null")] pub ai_context_path: Option, - /// Backstage taxonomy kind (tier 1-5) + /// Project taxonomy kind (tier 1-5) #[serde(default)] pub kind: Option, diff --git a/src/ui/create_dialog.rs b/src/ui/create_dialog.rs index 2df2262..389ea21 100644 --- a/src/ui/create_dialog.rs +++ b/src/ui/create_dialog.rs @@ -221,23 +221,19 @@ impl CreateDialog { let requires_project = !self.selected_template.is_none_or(|t| t.project_optional()); match key { - KeyCode::Up | KeyCode::Char('k') => { - if !list.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i > 0 => i - 1, - _ => list.len().saturating_sub(1), - }; - self.project_state.select(Some(i)); - } + KeyCode::Up | KeyCode::Char('k') if !list.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i > 0 => i - 1, + _ => list.len().saturating_sub(1), + }; + self.project_state.select(Some(i)); } - KeyCode::Down | KeyCode::Char('j') => { - if !list.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i < list.len() - 1 => i + 1, - _ => 0, - }; - self.project_state.select(Some(i)); - } + KeyCode::Down | KeyCode::Char('j') if !list.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i < list.len() - 1 => i + 1, + _ => 0, + }; + self.project_state.select(Some(i)); } KeyCode::Enter => { // Block proceeding if projects are required but none found diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 5646e0d..ad80368 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -10,12 +10,8 @@ use ratatui::{ use super::in_progress_panel::InProgressPanel; use super::panels::{CompletedPanel, HeaderBar, QueuePanel, StatusBar}; -use super::status_panel::{ - DelegatorInfo, KanbanProviderInfo, LlmToolInfo, StatusPanel, StatusSnapshot, - WrapperConnectionStatus, -}; -use crate::backstage::ServerStatus; -use crate::config::{Config, GitProviderConfig, SessionWrapperType}; +use super::status_panel::{IssueTypeInfo, StatusPanel, StatusSnapshot, WrapperConnectionStatus}; +use crate::config::{Config, SessionWrapperType}; use crate::editors::EditorConfig; use crate::queue::Ticket; use crate::rest::RestApiStatus; @@ -37,8 +33,6 @@ pub struct Dashboard { pub focused: FocusedPanel, pub paused: bool, pub max_agents: usize, - /// Backstage server status - pub backstage_status: ServerStatus, /// REST API server status pub rest_api_status: RestApiStatus, /// Wrapper display name for header bar @@ -55,8 +49,16 @@ pub struct Dashboard { pub wrapper_connection_status: WrapperConnectionStatus, /// Config snapshot for status panel config: Config, + /// Active issue types, loaded once from the registry (refreshed on config reload). + /// Cached because loading the registry touches the filesystem. + issue_types: Vec, /// Resolved editor environment variables pub editor_config: EditorConfig, + /// Active MCP SSE sessions, updated by the app each tick via `update_mcp_active_sessions`. + pub mcp_active_sessions: usize, + /// ACP agent advertisement + active-session count. Updated by `App` on + /// construction; refreshed by `update_acp_status` if it changes later. + pub acp_status: crate::acp::AcpAgentStatus, } impl Dashboard { @@ -70,7 +72,6 @@ impl Dashboard { paused: false, max_agents: config.effective_max_agents(), wrapper_name: config.sessions.wrapper.display_name(), - backstage_status: ServerStatus::Stopped, rest_api_status: RestApiStatus::Stopped, exit_confirmation_mode: false, update_available_version: None, @@ -78,7 +79,10 @@ impl Dashboard { status_message_at: None, wrapper_connection_status: Self::initial_wrapper_status(config), config: config.clone(), + issue_types: Self::load_issue_types(config), editor_config: EditorConfig::detect(config.sessions.wrapper), + mcp_active_sessions: 0, + acp_status: crate::acp::AcpAgentServer::from_config(config).status(), }; dashboard.compute_initial_focus(); dashboard @@ -103,14 +107,16 @@ impl Dashboard { } } - pub fn update_backstage_status(&mut self, status: ServerStatus) { - self.backstage_status = status; - } - pub fn update_rest_api_status(&mut self, status: RestApiStatus) { self.rest_api_status = status; } + /// Update the active MCP SSE session count. Called each tick by the app + /// from `rest_api_server.api_state().map(|s| s.mcp_sessions.try_lock()...)`. + pub fn update_mcp_active_sessions(&mut self, count: usize) { + self.mcp_active_sessions = count; + } + pub fn update_exit_confirmation_mode(&mut self, mode: bool) { self.exit_confirmation_mode = mode; } @@ -137,6 +143,28 @@ impl Dashboard { pub fn update_config(&mut self, config: &Config) { self.config = config.clone(); + self.issue_types = Self::load_issue_types(config); + } + + /// Load active issue types from the registry. Touches the filesystem, so the + /// result is cached on the `Dashboard` rather than recomputed each render. + fn load_issue_types(config: &Config) -> Vec { + let mut registry = crate::issuetypes::IssueTypeRegistry::new(); + // `load_all` always loads builtins first, so the list is non-empty even + // when no user types or templates are present. + let _ = registry.load_all(Path::new(&config.paths.tickets)); + registry + .all_types() + .map(|it| IssueTypeInfo { + key: it.key.clone(), + name: it.name.clone(), + mode: if it.is_autonomous() { + "autonomous".to_string() + } else { + "paired".to_string() + }, + }) + .collect() } pub fn expand_and_focus_section(&mut self, section_id: super::status_panel::SectionId) { @@ -200,120 +228,34 @@ impl Dashboard { self.wrapper_connection_status = status; } - /// Build a status snapshot from current config and runtime state + /// Build a status snapshot from current config and runtime state. + /// + /// The config-derived fields come from [`StatusSnapshot::from_config`] (shared + /// with the REST `/api/v1/sections` endpoint); this method then overrides the + /// live runtime fields the TUI tracks (API/wrapper/mcp/acp liveness, editor env). fn build_status_snapshot(&self) -> StatusSnapshot { - let config = &self.config; - - // Working directory is where the operator process runs from - let working_dir = std::env::current_dir() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(); - let config_path = Config::operator_config_path() - .to_string_lossy() - .into_owned(); - let tickets_dir = config.paths.tickets.clone(); - let tickets_dir_exists = Path::new(&tickets_dir).exists(); - - // Build kanban provider info from jira + linear configs - let mut kanban_providers: Vec = Vec::new(); - for domain in config.kanban.jira.keys() { - kanban_providers.push(KanbanProviderInfo { - provider_type: "jira".to_string(), - domain: domain.clone(), - }); - } - for slug in config.kanban.linear.keys() { - kanban_providers.push(KanbanProviderInfo { - provider_type: "linear".to_string(), - domain: slug.clone(), - }); - } - - // Build LLM tool info from detected tools - let llm_tools: Vec = config - .llm_tools - .detected - .iter() - .map(|t| LlmToolInfo { - name: t.name.clone(), - version: t.version.clone(), - model_aliases: t.model_aliases.clone(), - }) - .collect(); - - // Build delegator info - let delegators: Vec = config - .delegators - .iter() - .map(|d| DelegatorInfo { - name: d.name.clone(), - display_name: d.display_name.clone(), - llm_tool: d.llm_tool.clone(), - model: d.model.clone(), - yolo: d.launch_config.as_ref().is_some_and(|lc| lc.yolo), - model_server: d.model_server.clone(), - }) - .collect(); - - // Build model server info — implicit builtins plus any user-declared. - let mut model_servers: Vec = config - .model_servers - .iter() - .map(|s| crate::ui::status_panel::ModelServerInfo { - name: s.name.clone(), - kind: s.kind.clone(), - base_url: s.base_url.clone(), - display_name: s.display_name.clone(), - user_declared: true, - }) - .collect(); - for tool in ["claude", "codex", "gemini"] { - let implicit = crate::config::implicit_model_server_for_tool(tool); - if !model_servers.iter().any(|s| s.name == implicit.name) { - model_servers.push(crate::ui::status_panel::ModelServerInfo { - name: implicit.name, - kind: implicit.kind, - base_url: implicit.base_url, - display_name: implicit.display_name, - user_declared: false, - }); + let mut snapshot = StatusSnapshot::from_config(&self.config, self.issue_types.clone()); + + snapshot.api_status = self.rest_api_status.clone(); + snapshot.update_available_version = self.update_available_version.clone(); + snapshot.wrapper_connection_status = self.wrapper_connection_status.clone(); + snapshot.env_editor = self.editor_config.editor.clone(); + snapshot.env_visual = self.editor_config.visual.clone(); + snapshot.mcp_http_status = if self.config.mcp.http_enabled { + match &self.rest_api_status { + RestApiStatus::Running { port } => { + crate::ui::status_panel::McpHttpStatus::Mounted { port: *port } + } + _ => crate::ui::status_panel::McpHttpStatus::NotMounted, } - } - - // Git config - let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); - let git_token_set = match config.git.provider { - Some(GitProviderConfig::GitLab) => std::env::var(&config.git.gitlab.token_env).is_ok(), - // GitHub is the default for all other providers (including None) - _ => std::env::var(&config.git.github.token_env).is_ok(), + } else { + crate::ui::status_panel::McpHttpStatus::NotMounted }; + snapshot.mcp_active_sessions = self.mcp_active_sessions; + snapshot.acp_stdio_advertised = self.acp_status.is_advertised(); + snapshot.acp_active_sessions = self.acp_status.active_sessions(); - StatusSnapshot { - working_dir, - config_file_found: true, // We have a config if we're running - config_path, - tickets_dir, - tickets_dir_exists, - wrapper_type: config.sessions.wrapper.display_name().to_string(), - operator_version: env!("CARGO_PKG_VERSION").to_string(), - api_status: self.rest_api_status.clone(), - backstage_status: self.backstage_status.clone(), - backstage_display: config.backstage.display, - kanban_providers, - llm_tools, - default_llm_tool: config.llm_tools.default_tool.clone(), - default_llm_model: config.llm_tools.default_model.clone(), - delegators, - model_servers, - git_provider, - git_token_set, - git_branch_format: Some(config.git.branch_format.clone()), - git_use_worktrees: config.git.use_worktrees, - update_available_version: self.update_available_version.clone(), - wrapper_connection_status: self.wrapper_connection_status.clone(), - env_editor: self.editor_config.editor.clone(), - env_visual: self.editor_config.visual.clone(), - } + snapshot } pub fn render(&mut self, frame: &mut Frame) { @@ -377,16 +319,23 @@ impl Dashboard { self.focused == FocusedPanel::Completed, ); - // Status bar + // Status bar — show dynamic hints when status panel is focused + let row_hints = if self.focused == FocusedPanel::Status { + let snapshot = self.build_status_snapshot(); + self.status_panel.current_row_hints(&snapshot) + } else { + None + }; let status = StatusBar { paused: self.paused, agent_count: self.in_progress_panel.agents.len(), max_agents: self.max_agents, - backstage_status: self.backstage_status.clone(), rest_api_status: self.rest_api_status.clone(), + embed_ui_available: cfg!(feature = "embed-ui"), exit_confirmation_mode: self.exit_confirmation_mode, update_available_version: self.update_available_version.clone(), status_message: self.status_message.clone(), + row_hints, }; status.render(frame, chunks[2]); } diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index ea3e513..d1cb088 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -80,7 +80,8 @@ impl InProgressPanel { // Tool indicator (A=Anthropic/Claude, G=Gemini, O=OpenAI/Codex) let tool_indicator = match a.llm_tool.as_deref() { - Some("claude") => ("A", Color::Rgb(193, 95, 60)), + // Brand salmon #E05D44 (matches tokens.css --color-salmon) + Some("claude") => ("A", Color::Rgb(224, 93, 68)), Some("gemini") => ("G", Color::Rgb(111, 66, 193)), Some("codex") => ("O", Color::Green), _ => (" ", Color::Reset), diff --git a/src/ui/keybindings.rs b/src/ui/keybindings.rs index 33090a7..43e4120 100644 --- a/src/ui/keybindings.rs +++ b/src/ui/keybindings.rs @@ -293,7 +293,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ key: KeyCode::Char('W'), modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('w')), - description: "Toggle Backstage server", + description: "Open web UI in browser", category: ShortcutCategory::Actions, context: ShortcutContext::Global, }, diff --git a/src/ui/panels.rs b/src/ui/panels.rs index e3c9a44..3ecd4cf 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -6,11 +6,11 @@ use ratatui::{ Frame, }; -use crate::backstage::ServerStatus; use crate::queue::Ticket; use crate::rest::RestApiStatus; use crate::state::CompletedTicket; use crate::templates::{color_for_key, glyph_for_key}; +use crate::ui::status_panel::RowHints; /// Format the ticket ID for display. /// The `ticket_id` field already contains the full ID (e.g., "FEAT-1234"), @@ -152,11 +152,12 @@ pub struct StatusBar { pub paused: bool, pub agent_count: usize, pub max_agents: usize, - pub backstage_status: ServerStatus, pub rest_api_status: RestApiStatus, + pub embed_ui_available: bool, pub exit_confirmation_mode: bool, pub update_available_version: Option, pub status_message: Option, + pub row_hints: Option, } impl StatusBar { @@ -187,6 +188,38 @@ impl StatusBar { ) } + /// Build context-sensitive hints from the selected status panel row. + fn build_dynamic_hints(hints: &RowHints, width: u16) -> Span<'static> { + let mut parts: Vec = Vec::new(); + + if let Some(verb) = hints.primary_verb { + parts.push(format!("[Enter] {verb}")); + } + if let Some(title) = hints.special_title { + parts.push(format!("[⇧Enter] {title}")); + } + if let Some(title) = hints.refresh_title { + parts.push(format!("[^Enter] {title}")); + } + + if parts.is_empty() { + return Self::build_hints(width); + } + + let full = parts.join(" "); + let hint_text = if (full.len() + 2) <= width as usize { + full + } else if parts.len() > 2 { + parts[..2].join(" ") + } else if parts.len() > 1 { + parts[0].clone() + } else { + parts.first().cloned().unwrap_or_default() + }; + + Span::styled(format!(" {hint_text}"), Style::default().fg(Color::Cyan)) + } + pub fn render(&self, frame: &mut Frame, area: Rect) { // Exit confirmation mode - show only the exit message (highest priority) if self.exit_confirmation_mode { @@ -234,30 +267,14 @@ impl StatusBar { Style::default().fg(Color::Gray), ); - // Web server indicator - shows combined status of both servers - // ● green = both running, ● yellow = starting/stopping, ● red = error, ○ white = stopped - let web_indicator = match (&self.backstage_status, &self.rest_api_status) { - // Both running - green filled circle with port - (ServerStatus::Running { port, .. }, RestApiStatus::Running { .. }) => Span::styled( - format!(" [W]eb ●:{port}"), - Style::default().fg(Color::Green), - ), - // Either starting or stopping - yellow filled circle - (ServerStatus::Starting | ServerStatus::Stopping, _) - | (_, RestApiStatus::Starting | RestApiStatus::Stopping) => { - Span::styled(" [W]eb ●", Style::default().fg(Color::Yellow)) - } - // Either errored - red filled circle - (ServerStatus::Error(_), _) | (_, RestApiStatus::Error(_)) => { - Span::styled(" [W]eb ●", Style::default().fg(Color::Red)) - } - // Both stopped - white hollow circle - _ => Span::styled(" [W]eb ○", Style::default().fg(Color::White)), - }; + let web_ind = web_indicator(&self.rest_api_status, self.embed_ui_available); - let help = Self::build_hints(area.width); + let help = match &self.row_hints { + Some(hints) => Self::build_dynamic_hints(hints, area.width), + None => Self::build_hints(area.width), + }; - let mut spans = vec![status, agents, web_indicator]; + let mut spans = vec![status, agents, web_ind]; // Show transient status message if present if let Some(ref msg) = self.status_message { @@ -308,6 +325,26 @@ impl HeaderBar { } } +/// Build a status indicator span for the REST API / embedded web UI. +fn web_indicator(status: &RestApiStatus, embed_ui: bool) -> Span<'static> { + let label = if embed_ui { "[W]eb" } else { "[A]PI" }; + match status { + RestApiStatus::Running { port } => Span::styled( + format!(" {label} ●:{port}"), + Style::default().fg(Color::Green), + ), + RestApiStatus::Starting | RestApiStatus::Stopping => { + Span::styled(format!(" {label} ●"), Style::default().fg(Color::Yellow)) + } + RestApiStatus::Error(_) => { + Span::styled(format!(" {label} ●"), Style::default().fg(Color::Red)) + } + RestApiStatus::Stopped => { + Span::styled(format!(" {label} ○"), Style::default().fg(Color::White)) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -420,4 +457,130 @@ mod tests { "Minimal width should NOT include [S]ync" ); } + + #[test] + fn test_web_indicator_running() { + let span = web_indicator(&RestApiStatus::Running { port: 7008 }, true); + let text: &str = &span.content; + assert!(text.contains("●:7008"), "should show port: {text}"); + assert_eq!(span.style.fg, Some(Color::Green)); + } + + #[test] + fn test_web_indicator_starting() { + let span = web_indicator(&RestApiStatus::Starting, true); + assert_eq!(span.style.fg, Some(Color::Yellow)); + } + + #[test] + fn test_web_indicator_stopping() { + let span = web_indicator(&RestApiStatus::Stopping, true); + assert_eq!(span.style.fg, Some(Color::Yellow)); + } + + #[test] + fn test_web_indicator_error() { + let span = web_indicator(&RestApiStatus::Error("bind failed".into()), true); + assert_eq!(span.style.fg, Some(Color::Red)); + } + + #[test] + fn test_web_indicator_stopped() { + let span = web_indicator(&RestApiStatus::Stopped, true); + let text: &str = &span.content; + assert!(text.contains('○'), "should show hollow circle: {text}"); + assert_eq!(span.style.fg, Some(Color::White)); + } + + #[test] + fn test_web_indicator_label_with_embed_ui() { + let span = web_indicator(&RestApiStatus::Stopped, true); + let text: &str = &span.content; + assert!( + text.contains("[W]eb"), + "embed_ui=true should show [W]eb: {text}" + ); + } + + #[test] + fn test_web_indicator_label_without_embed_ui() { + let span = web_indicator(&RestApiStatus::Stopped, false); + let text: &str = &span.content; + assert!( + text.contains("[A]PI"), + "embed_ui=false should show [A]PI: {text}" + ); + } + + #[test] + fn test_build_dynamic_hints_all_actions() { + let hints = RowHints { + primary_verb: Some("Edit"), + special_title: Some("Reset"), + refresh_title: Some("Reload"), + }; + let span = StatusBar::build_dynamic_hints(&hints, 120); + let text: &str = &span.content; + assert!(text.contains("[Enter] Edit"), "should show primary: {text}"); + assert!( + text.contains("[⇧Enter] Reset"), + "should show special: {text}" + ); + assert!( + text.contains("[^Enter] Reload"), + "should show refresh: {text}" + ); + assert_eq!(span.style.fg, Some(Color::Cyan)); + } + + #[test] + fn test_build_dynamic_hints_primary_only() { + let hints = RowHints { + primary_verb: Some("Open"), + special_title: None, + refresh_title: None, + }; + let span = StatusBar::build_dynamic_hints(&hints, 120); + let text: &str = &span.content; + assert!(text.contains("[Enter] Open"), "should show primary: {text}"); + assert!( + !text.contains("[⇧Enter]"), + "should not show special: {text}" + ); + assert!( + !text.contains("[^Enter]"), + "should not show refresh: {text}" + ); + } + + #[test] + fn test_build_dynamic_hints_no_actions_falls_back() { + let hints = RowHints { + primary_verb: None, + special_title: None, + refresh_title: None, + }; + let span = StatusBar::build_dynamic_hints(&hints, 120); + let text: &str = &span.content; + assert!( + text.contains("[Q]ueue") || text.contains("[?]Help"), + "should fall back to static hints: {text}" + ); + assert_eq!(span.style.fg, Some(Color::DarkGray)); + } + + #[test] + fn test_build_dynamic_hints_truncates_at_narrow_width() { + let hints = RowHints { + primary_verb: Some("Edit"), + special_title: Some("Reset"), + refresh_title: Some("Reload"), + }; + let span = StatusBar::build_dynamic_hints(&hints, 40); + let text: &str = &span.content; + assert!( + !text.contains("[^Enter] Reload"), + "narrow width should drop refresh: {text}" + ); + } } diff --git a/src/ui/projects_dialog.rs b/src/ui/projects_dialog.rs index 1cce46b..dbc09c1 100644 --- a/src/ui/projects_dialog.rs +++ b/src/ui/projects_dialog.rs @@ -44,7 +44,7 @@ impl ProjectAction { pub fn label(&self) -> &'static str { match self { ProjectAction::AddOperatorAgents => "Add Operator agents", - ProjectAction::AssessProject => "Assess for Backstage", + ProjectAction::AssessProject => "Assess Project", } } @@ -188,23 +188,19 @@ impl ProjectsDialog { fn handle_project_key(&mut self, key: KeyCode) -> Option { match key { - KeyCode::Up | KeyCode::Char('k') => { - if !self.projects.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i > 0 => i - 1, - _ => self.projects.len().saturating_sub(1), - }; - self.project_state.select(Some(i)); - } + KeyCode::Up | KeyCode::Char('k') if !self.projects.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i > 0 => i - 1, + _ => self.projects.len().saturating_sub(1), + }; + self.project_state.select(Some(i)); } - KeyCode::Down | KeyCode::Char('j') => { - if !self.projects.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i < self.projects.len() - 1 => i + 1, - _ => 0, - }; - self.project_state.select(Some(i)); - } + KeyCode::Down | KeyCode::Char('j') if !self.projects.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i < self.projects.len() - 1 => i + 1, + _ => 0, + }; + self.project_state.select(Some(i)); } KeyCode::Enter => { if let Some(i) = self.project_state.selected() { @@ -224,23 +220,19 @@ impl ProjectsDialog { fn handle_action_key(&mut self, key: KeyCode) -> Option { let actions = ProjectAction::all(); match key { - KeyCode::Up | KeyCode::Char('k') => { - if !actions.is_empty() { - let i = match self.action_state.selected() { - Some(i) if i > 0 => i - 1, - _ => actions.len().saturating_sub(1), - }; - self.action_state.select(Some(i)); - } + KeyCode::Up | KeyCode::Char('k') if !actions.is_empty() => { + let i = match self.action_state.selected() { + Some(i) if i > 0 => i - 1, + _ => actions.len().saturating_sub(1), + }; + self.action_state.select(Some(i)); } - KeyCode::Down | KeyCode::Char('j') => { - if !actions.is_empty() { - let i = match self.action_state.selected() { - Some(i) if i < actions.len() - 1 => i + 1, - _ => 0, - }; - self.action_state.select(Some(i)); - } + KeyCode::Down | KeyCode::Char('j') if !actions.is_empty() => { + let i = match self.action_state.selected() { + Some(i) if i < actions.len() - 1 => i + 1, + _ => 0, + }; + self.action_state.select(Some(i)); } KeyCode::Enter => { if let Some(i) = self.action_state.selected() { @@ -624,7 +616,7 @@ mod tests { ProjectAction::AddOperatorAgents.label(), "Add Operator agents" ); - assert_eq!(ProjectAction::AssessProject.label(), "Assess for Backstage"); + assert_eq!(ProjectAction::AssessProject.label(), "Assess Project"); } #[test] diff --git a/src/ui/sections/config_section.rs b/src/ui/sections/config_section.rs index 8a848bb..c9b4817 100644 --- a/src/ui/sections/config_section.rs +++ b/src/ui/sections/config_section.rs @@ -51,6 +51,7 @@ impl StatusSection for ConfigSection { // Working Dir: primary=open, special=none (must launch from dir), refresh=none TreeRow { section_id: SectionId::Configuration, + id: "working-dir".into(), depth: 1, label: "Working Dir".into(), description: if snapshot.working_dir.is_empty() { @@ -77,6 +78,7 @@ impl StatusSection for ConfigSection { // Config: primary=edit, special=reset to defaults, refresh=reload config TreeRow { section_id: SectionId::Configuration, + id: "config-file".into(), depth: 1, label: "Config".into(), description: if snapshot.config_file_found { @@ -114,6 +116,7 @@ impl StatusSection for ConfigSection { // Tickets: primary=open dir, no special or refresh TreeRow { section_id: SectionId::Configuration, + id: "tickets-dir".into(), depth: 1, label: "Tickets".into(), description: if snapshot.tickets_dir_exists { @@ -139,6 +142,7 @@ impl StatusSection for ConfigSection { let wrapper = &snapshot.wrapper_connection_status; TreeRow { section_id: SectionId::Configuration, + id: "wrapper-connection".into(), depth: 1, label: wrapper.label().into(), description: wrapper.description(), @@ -169,6 +173,7 @@ impl StatusSection for ConfigSection { // Wrapper type: display-only TreeRow { section_id: SectionId::Configuration, + id: "wrapper-type".into(), depth: 1, label: "Wrapper".into(), description: snapshot.wrapper_type.clone(), @@ -180,6 +185,7 @@ impl StatusSection for ConfigSection { // $EDITOR: display-only TreeRow { section_id: SectionId::Configuration, + id: "editor".into(), depth: 1, label: "$EDITOR".into(), description: if snapshot.env_editor.is_empty() { @@ -199,6 +205,7 @@ impl StatusSection for ConfigSection { // $VISUAL: display-only TreeRow { section_id: SectionId::Configuration, + id: "visual".into(), depth: 1, label: "$VISUAL".into(), description: if snapshot.env_visual.is_empty() { @@ -218,6 +225,7 @@ impl StatusSection for ConfigSection { // Version: primary=open downloads, refresh=check for updates TreeRow { section_id: SectionId::Configuration, + id: "version".into(), depth: 1, label: "Version".into(), description: if let Some(ref update) = snapshot.update_available_version { diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs index 3364e7a..7597a99 100644 --- a/src/ui/sections/connections_section.rs +++ b/src/ui/sections/connections_section.rs @@ -1,8 +1,7 @@ -use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ - ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, - StatusSnapshot, TreeRow, WrapperConnectionStatus, + ActionMeta, ActionSet, McpHttpStatus, SectionHealth, SectionId, StatusAction, StatusIcon, + StatusSection, StatusSnapshot, TreeRow, WrapperConnectionStatus, }; pub struct ConnectionsSection; @@ -25,28 +24,11 @@ impl StatusSection for ConnectionsSection { let api_starting = matches!(snapshot.api_status, RestApiStatus::Starting); let wrapper_ok = snapshot.wrapper_connection_status.is_connected(); - // When backstage is hidden, health is based on API + wrapper only - if !snapshot.backstage_display { - return match (api_ok, wrapper_ok) { - (true, true) => SectionHealth::Green, - _ if api_starting => SectionHealth::Yellow, - (true, false) | (false, true) => SectionHealth::Yellow, - (false, false) => SectionHealth::Red, - }; - } - - // When backstage is displayed, include it in health - let bs_ok = matches!(snapshot.backstage_status, ServerStatus::Running { .. }); - let bs_starting = matches!(snapshot.backstage_status, ServerStatus::Starting); - let all_ok = api_ok && bs_ok && wrapper_ok; - let any_starting = api_starting || bs_starting; - - if all_ok { - SectionHealth::Green - } else if any_starting || api_ok || bs_ok || wrapper_ok { - SectionHealth::Yellow - } else { - SectionHealth::Red + match (api_ok, wrapper_ok) { + (true, true) => SectionHealth::Green, + _ if api_starting => SectionHealth::Yellow, + (true, false) | (false, true) => SectionHealth::Yellow, + (false, false) => SectionHealth::Red, } } @@ -72,6 +54,7 @@ impl StatusSection for ConnectionsSection { // 1. Operator API TreeRow { section_id: SectionId::Connections, + id: "operator-api".into(), depth: 1, label: "Operator API".into(), description: match &snapshot.api_status { @@ -116,24 +99,130 @@ impl StatusSection for ConnectionsSection { }, ]; - // 2. Backstage (conditionally displayed) - if snapshot.backstage_display { + // 2. Web UI (when embed-ui feature is compiled in) + if snapshot.embed_ui_available { rows.push(TreeRow { section_id: SectionId::Connections, + id: "web-ui".into(), depth: 1, - label: "Backstage".into(), - description: format!("{:?}", snapshot.backstage_status), - icon: if matches!(snapshot.backstage_status, ServerStatus::Running { .. }) { - StatusIcon::Check - } else { - StatusIcon::Cross + label: "Web UI".into(), + description: match &snapshot.api_status { + RestApiStatus::Running { port } => format!(":{port}"), + RestApiStatus::Starting => "Starting...".into(), + _ => "API stopped".into(), + }, + icon: match &snapshot.api_status { + RestApiStatus::Running { .. } => StatusIcon::Check, + RestApiStatus::Starting => StatusIcon::Warning, + _ => StatusIcon::Cross, }, is_header: false, - actions: ActionSet::primary(StatusAction::ToggleWebServers), + actions: ActionSet { + primary: match &snapshot.api_status { + RestApiStatus::Running { port } => StatusAction::OpenWebUi { port: *port }, + RestApiStatus::Stopped | RestApiStatus::Error(_) => StatusAction::StartApi, + _ => StatusAction::None, + }, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + }, health: SectionHealth::Gray, }); } + // 3. MCP (always shown). Status reflects HTTP mount + stdio advertise + session count. + rows.push(TreeRow { + section_id: SectionId::Connections, + id: "mcp".into(), + depth: 1, + label: "MCP".into(), + description: match ( + &snapshot.mcp_http_status, + snapshot.mcp_stdio_advertised, + snapshot.mcp_active_sessions, + ) { + (McpHttpStatus::Mounted { port }, true, n) if n > 0 => { + format!(":{port} + stdio · {n} sessions") + } + (McpHttpStatus::Mounted { port }, true, _) => format!(":{port} + stdio"), + (McpHttpStatus::Mounted { port }, false, n) if n > 0 => { + format!(":{port} · {n} sessions") + } + (McpHttpStatus::Mounted { port }, false, _) => format!(":{port} (HTTP only)"), + (McpHttpStatus::NotMounted, true, _) => "stdio only".into(), + (McpHttpStatus::NotMounted, false, _) => "Disabled".into(), + }, + icon: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised) { + (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Check, + _ => StatusIcon::Cross, + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::WriteAndOpenMcpClientConfig { + client: "claude-code".to_string(), + }, + back: StatusAction::None, + special: StatusAction::ToggleMcpHttp, + special_meta: Some(ActionMeta { + title: "HTTP", + tooltip: "Toggle the MCP HTTP transport (restart required)", + }), + refresh: StatusAction::OpenMcpDocs, + refresh_meta: Some(ActionMeta { + title: "Docs", + tooltip: "Open MCP setup docs in browser", + }), + }, + health: SectionHealth::Gray, + }); + + // 3. ACP (always shown). Status reflects whether operator + // advertises itself as an ACP agent. Active session count is + // always 0 in v1 (editor-spawned ACP runs out-of-process). + rows.push(TreeRow { + section_id: SectionId::Connections, + id: "acp".into(), + depth: 1, + label: "ACP".into(), + description: if snapshot.acp_stdio_advertised { + if snapshot.acp_active_sessions > 0 { + format!("stdio · {} sessions", snapshot.acp_active_sessions) + } else { + "stdio ready".into() + } + } else { + "Disabled".into() + }, + icon: if snapshot.acp_stdio_advertised { + StatusIcon::Check + } else { + StatusIcon::Cross + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::WriteAndOpenAcpEditorConfig { + editor: "zed".to_string(), + }, + back: StatusAction::None, + special: StatusAction::WriteAndOpenAcpEditorConfig { + editor: "jetbrains".to_string(), + }, + special_meta: Some(ActionMeta { + title: "JBrn", + tooltip: "Write JetBrains ACP registry snippet", + }), + refresh: StatusAction::OpenAcpDocs, + refresh_meta: Some(ActionMeta { + title: "Docs", + tooltip: "Open ACP setup docs in browser", + }), + }, + health: SectionHealth::Gray, + }); + rows } } @@ -153,14 +242,14 @@ mod tests { wrapper_type: "tmux".into(), operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 7008 }, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators: vec![], model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -173,6 +262,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), + mcp_http_status: McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } @@ -206,6 +301,37 @@ mod tests { assert_eq!(section.health(&snap), SectionHealth::Red); } + #[test] + fn test_connections_acp_row_present_when_advertised() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let acp_row = children + .iter() + .find(|r| r.label == "ACP") + .expect("ACP row must be in the connections section"); + assert!(matches!(acp_row.icon, StatusIcon::Check)); + assert_eq!(acp_row.description, "stdio ready"); + assert_eq!( + acp_row.actions.primary, + StatusAction::WriteAndOpenAcpEditorConfig { + editor: "zed".to_string() + } + ); + assert_eq!(acp_row.actions.refresh, StatusAction::OpenAcpDocs); + } + + #[test] + fn test_connections_acp_row_disabled_when_flag_off() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.acp_stdio_advertised = false; + let children = section.children(&snap); + let acp_row = children.iter().find(|r| r.label == "ACP").unwrap(); + assert!(matches!(acp_row.icon, StatusIcon::Cross)); + assert_eq!(acp_row.description, "Disabled"); + } + #[test] fn test_connections_api_running_opens_swagger() { let section = ConnectionsSection; @@ -229,25 +355,93 @@ mod tests { } #[test] - fn test_connections_backstage_hidden_by_default() { + fn test_connections_web_ui_row_present_when_embed_ui_available() { let section = ConnectionsSection; let snap = base_snapshot(); let children = section.children(&snap); + let web_ui_row = children + .iter() + .find(|r| r.label == "Web UI") + .expect("Web UI row must be present when embed_ui_available is true"); + assert!(matches!(web_ui_row.icon, StatusIcon::Check)); + assert_eq!(web_ui_row.description, ":7008"); + assert_eq!( + web_ui_row.actions.primary, + StatusAction::OpenWebUi { port: 7008 } + ); + } + + #[test] + fn test_connections_web_ui_row_absent_when_embed_ui_unavailable() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.embed_ui_available = false; + let children = section.children(&snap); assert!( - !children.iter().any(|r| r.label == "Backstage"), - "Backstage should be hidden when backstage_display is false" + !children.iter().any(|r| r.label == "Web UI"), + "Web UI row should be hidden when embed_ui_available is false" ); } #[test] - fn test_connections_backstage_shown_when_display_true() { + fn test_connections_web_ui_row_starts_api_when_stopped() { let section = ConnectionsSection; let mut snap = base_snapshot(); - snap.backstage_display = true; + snap.api_status = RestApiStatus::Stopped; + let children = section.children(&snap); + let web_ui_row = children.iter().find(|r| r.label == "Web UI").unwrap(); + assert!(matches!(web_ui_row.icon, StatusIcon::Cross)); + assert_eq!(web_ui_row.description, "API stopped"); + assert_eq!(web_ui_row.actions.primary, StatusAction::StartApi); + } + + #[test] + fn test_connections_mcp_row_always_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); let children = section.children(&snap); assert!( - children.iter().any(|r| r.label == "Backstage"), - "Backstage should be shown when backstage_display is true" + children.iter().any(|r| r.label == "MCP"), + "MCP row should always be present" + ); + } + + #[test] + fn test_connections_mcp_row_disabled_description() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = false; + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + assert_eq!(mcp_row.description, "Disabled"); + } + + #[test] + fn test_connections_mcp_row_stdio_only_description() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = true; + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + assert_eq!(mcp_row.description, "stdio only"); + } + + #[test] + fn test_connections_mcp_row_actions() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + // Primary writes the claude-code snippet by default. + assert_eq!( + mcp_row.actions.primary, + StatusAction::WriteAndOpenMcpClientConfig { + client: "claude-code".to_string() + } ); + assert_eq!(mcp_row.actions.special, StatusAction::ToggleMcpHttp); + assert_eq!(mcp_row.actions.refresh, StatusAction::OpenMcpDocs); } } diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs index 252b55e..a6a7c5c 100644 --- a/src/ui/sections/delegator_section.rs +++ b/src/ui/sections/delegator_section.rs @@ -39,6 +39,7 @@ impl StatusSection for DelegatorSection { if snapshot.delegators.is_empty() { return vec![TreeRow { section_id: SectionId::Delegators, + id: "add-delegator".into(), depth: 1, label: "Add delegator".into(), description: "Edit config to configure a delegator".into(), @@ -65,6 +66,7 @@ impl StatusSection for DelegatorSection { TreeRow { section_id: SectionId::Delegators, + id: d.name.clone(), depth: 1, label, description, @@ -91,7 +93,6 @@ impl StatusSection for DelegatorSection { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{DelegatorInfo, WrapperConnectionStatus}; @@ -105,14 +106,14 @@ mod tests { wrapper_type: "tmux".into(), operator_version: "0.1.30".into(), api_status: RestApiStatus::Stopped, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators, model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -125,6 +126,12 @@ mod tests { }, env_editor: String::new(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::NotMounted, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } diff --git a/src/ui/sections/git_section.rs b/src/ui/sections/git_section.rs index 2f31845..7c5cac2 100644 --- a/src/ui/sections/git_section.rs +++ b/src/ui/sections/git_section.rs @@ -39,6 +39,7 @@ impl StatusSection for GitSection { vec![ TreeRow { section_id: SectionId::Git, + id: "configure-github".into(), depth: 1, label: "Configure GitHub".into(), description: "Set up GitHub".into(), @@ -51,6 +52,7 @@ impl StatusSection for GitSection { }, TreeRow { section_id: SectionId::Git, + id: "configure-gitlab".into(), depth: 1, label: "Configure GitLab".into(), description: "Set up GitLab".into(), @@ -69,6 +71,7 @@ impl StatusSection for GitSection { let mut rows = vec![ TreeRow { section_id: SectionId::Git, + id: "git-provider".into(), depth: 1, label: "Provider".into(), description: provider.clone(), @@ -89,6 +92,7 @@ impl StatusSection for GitSection { }, TreeRow { section_id: SectionId::Git, + id: "git-token".into(), depth: 1, label: "Token".into(), description: if snapshot.git_token_set { @@ -116,6 +120,7 @@ impl StatusSection for GitSection { if let Some(ref fmt) = snapshot.git_branch_format { rows.push(TreeRow { section_id: SectionId::Git, + id: "git-branch-format".into(), depth: 1, label: "Branch Format".into(), description: fmt.clone(), @@ -128,6 +133,7 @@ impl StatusSection for GitSection { rows.push(TreeRow { section_id: SectionId::Git, + id: "git-worktrees".into(), depth: 1, label: "Worktrees".into(), description: if snapshot.git_use_worktrees { @@ -150,7 +156,6 @@ impl StatusSection for GitSection { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ DelegatorInfo, KanbanProviderInfo, LlmToolInfo, WrapperConnectionStatus, @@ -166,12 +171,12 @@ mod tests { wrapper_type: "tmux".into(), operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 7008 }, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], delegators: vec![], model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -186,6 +191,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } diff --git a/src/ui/sections/issuetype_section.rs b/src/ui/sections/issuetype_section.rs new file mode 100644 index 0000000..47776e4 --- /dev/null +++ b/src/ui/sections/issuetype_section.rs @@ -0,0 +1,164 @@ +use crate::ui::status_panel::{ + ActionSet, SectionHealth, SectionId, StatusIcon, StatusSection, StatusSnapshot, TreeRow, +}; + +/// Issue Types section — mirrors the VS Code extension's `IssueTypeSection`. +/// Visible once Kanban is configured; lists the active issue types. +pub struct IssueTypeSection; + +impl StatusSection for IssueTypeSection { + fn section_id(&self) -> SectionId { + SectionId::IssueTypes + } + + fn label(&self) -> &'static str { + "Issue Types" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Kanban] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.issue_types.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let count = snapshot.issue_types.len(); + if count == 0 { + "Not configured".into() + } else { + format!("{count} type{}", if count == 1 { "" } else { "s" }) + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + snapshot + .issue_types + .iter() + .map(|it| TreeRow { + section_id: SectionId::IssueTypes, + id: it.key.clone(), + depth: 1, + label: it.key.clone(), + description: format!("{} · {}", it.name, it.mode), + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{IssueTypeInfo, WrapperConnectionStatus}; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + kanban_providers: vec![], + llm_tools: vec![], + delegators: vec![], + model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + default_llm_tool: None, + default_llm_model: None, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, + } + } + + fn snapshot_with(issue_types: Vec) -> StatusSnapshot { + StatusSnapshot { + issue_types, + ..base_snapshot() + } + } + + fn sample_types() -> Vec { + vec![ + IssueTypeInfo { + key: "FEAT".into(), + name: "Feature".into(), + mode: "autonomous".into(), + }, + IssueTypeInfo { + key: "SPIKE".into(), + name: "Spike".into(), + mode: "paired".into(), + }, + ] + } + + #[test] + fn test_issuetype_prerequisite_is_kanban() { + assert_eq!(IssueTypeSection.prerequisites(), &[SectionId::Kanban]); + } + + #[test] + fn test_issuetype_health_yellow_when_empty() { + let snap = snapshot_with(vec![]); + assert_eq!(IssueTypeSection.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_issuetype_health_green_when_present() { + let snap = snapshot_with(sample_types()); + assert_eq!(IssueTypeSection.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_issuetype_description_counts_types() { + assert_eq!( + IssueTypeSection.description(&snapshot_with(vec![])), + "Not configured" + ); + assert_eq!( + IssueTypeSection.description(&snapshot_with(sample_types())), + "2 types" + ); + } + + #[test] + fn test_issuetype_children_render_key_name_mode() { + let snap = snapshot_with(sample_types()); + let children = IssueTypeSection.children(&snap); + assert_eq!(children.len(), 2); + assert_eq!(children[0].label, "FEAT"); + assert_eq!(children[0].description, "Feature · autonomous"); + assert_eq!(children[1].label, "SPIKE"); + assert_eq!(children[1].description, "Spike · paired"); + } +} diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs index e3a124a..b8e2032 100644 --- a/src/ui/sections/kanban_section.rs +++ b/src/ui/sections/kanban_section.rs @@ -39,6 +39,7 @@ impl StatusSection for KanbanSection { return vec![ TreeRow { section_id: SectionId::Kanban, + id: "configure-jira".into(), depth: 1, label: "Configure Jira".into(), description: "Connect to Jira Cloud".into(), @@ -51,6 +52,7 @@ impl StatusSection for KanbanSection { }, TreeRow { section_id: SectionId::Kanban, + id: "configure-linear".into(), depth: 1, label: "Configure Linear".into(), description: "Connect to Linear".into(), @@ -69,6 +71,7 @@ impl StatusSection for KanbanSection { .iter() .map(|provider| TreeRow { section_id: SectionId::Kanban, + id: provider.domain.clone(), depth: 1, label: provider.provider_type.clone(), description: provider.domain.clone(), @@ -94,7 +97,6 @@ impl StatusSection for KanbanSection { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ DelegatorInfo, KanbanProviderInfo, LlmToolInfo, WrapperConnectionStatus, @@ -110,14 +112,14 @@ mod tests { wrapper_type: "tmux".into(), operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 7008 }, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators: vec![], model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -130,6 +132,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs index 9f51413..793b426 100644 --- a/src/ui/sections/llm_section.rs +++ b/src/ui/sections/llm_section.rs @@ -45,6 +45,7 @@ impl StatusSection for LlmSection { // Depth 1: tool name + version rows.push(TreeRow { section_id: SectionId::LlmTools, + id: tool.name.clone(), depth: 1, label: tool.name.clone(), description: tool.version.clone(), @@ -81,6 +82,7 @@ impl StatusSection for LlmSection { rows.push(TreeRow { section_id: SectionId::LlmTools, + id: format!("{}:{}", tool.name, model), depth: 2, label, description: format!("{}:{}", tool.name, model), diff --git a/src/ui/sections/managed_projects_section.rs b/src/ui/sections/managed_projects_section.rs new file mode 100644 index 0000000..bb3e1b2 --- /dev/null +++ b/src/ui/sections/managed_projects_section.rs @@ -0,0 +1,172 @@ +use crate::ui::status_panel::{ + ActionSet, SectionHealth, SectionId, StatusIcon, StatusSection, StatusSnapshot, TreeRow, +}; + +/// Managed Projects section — mirrors the VS Code extension's `ManagedProjectsSection`. +/// Visible once Git is configured; lists the projects operator can assign work to. +pub struct ManagedProjectsSection; + +impl StatusSection for ManagedProjectsSection { + fn section_id(&self) -> SectionId { + SectionId::ManagedProjects + } + + fn label(&self) -> &'static str { + "Managed Projects" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Git] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.managed_projects.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let count = snapshot.managed_projects.len(); + if count == 0 { + "No projects configured".into() + } else { + format!("{count} project{}", if count == 1 { "" } else { "s" }) + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + snapshot + .managed_projects + .iter() + .map(|proj| TreeRow { + section_id: SectionId::ManagedProjects, + id: proj.name.clone(), + depth: 1, + label: proj.name.clone(), + description: if proj.exists { + String::new() + } else { + "missing".into() + }, + icon: if proj.exists { + StatusIcon::Folder + } else { + StatusIcon::Warning + }, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{ManagedProjectInfo, WrapperConnectionStatus}; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + kanban_providers: vec![], + llm_tools: vec![], + delegators: vec![], + model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + default_llm_tool: None, + default_llm_model: None, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, + } + } + + fn snapshot_with(managed_projects: Vec) -> StatusSnapshot { + StatusSnapshot { + managed_projects, + ..base_snapshot() + } + } + + #[test] + fn test_projects_prerequisite_is_git() { + assert_eq!(ManagedProjectsSection.prerequisites(), &[SectionId::Git]); + } + + #[test] + fn test_projects_health_yellow_when_empty() { + let snap = snapshot_with(vec![]); + assert_eq!(ManagedProjectsSection.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_projects_health_green_when_present() { + let snap = snapshot_with(vec![ManagedProjectInfo { + name: "operator".into(), + exists: true, + }]); + assert_eq!(ManagedProjectsSection.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_projects_description_counts_projects() { + assert_eq!( + ManagedProjectsSection.description(&snapshot_with(vec![])), + "No projects configured" + ); + let snap = snapshot_with(vec![ManagedProjectInfo { + name: "operator".into(), + exists: true, + }]); + assert_eq!(ManagedProjectsSection.description(&snap), "1 project"); + } + + #[test] + fn test_projects_children_flag_missing_dirs() { + let snap = snapshot_with(vec![ + ManagedProjectInfo { + name: "present".into(), + exists: true, + }, + ManagedProjectInfo { + name: "gone".into(), + exists: false, + }, + ]); + let children = ManagedProjectsSection.children(&snap); + assert_eq!(children.len(), 2); + assert_eq!(children[0].label, "present"); + assert_eq!(children[0].description, ""); + assert!(matches!(children[0].icon, StatusIcon::Folder)); + assert_eq!(children[1].label, "gone"); + assert_eq!(children[1].description, "missing"); + assert!(matches!(children[1].icon, StatusIcon::Warning)); + } +} diff --git a/src/ui/sections/mod.rs b/src/ui/sections/mod.rs index 4ac3546..cce63c0 100644 --- a/src/ui/sections/mod.rs +++ b/src/ui/sections/mod.rs @@ -2,14 +2,18 @@ mod config_section; mod connections_section; mod delegator_section; mod git_section; +mod issuetype_section; mod kanban_section; mod llm_section; +mod managed_projects_section; mod modelserver_section; pub use config_section::ConfigSection; pub use connections_section::ConnectionsSection; pub use delegator_section::DelegatorSection; pub use git_section::GitSection; +pub use issuetype_section::IssueTypeSection; pub use kanban_section::KanbanSection; pub use llm_section::LlmSection; +pub use managed_projects_section::ManagedProjectsSection; pub use modelserver_section::ModelServerSection; diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs index 3647828..edb564e 100644 --- a/src/ui/sections/modelserver_section.rs +++ b/src/ui/sections/modelserver_section.rs @@ -85,6 +85,7 @@ impl StatusSection for ModelServerSection { TreeRow { section_id: SectionId::ModelServers, + id: s.name.clone(), depth: 1, label, description, @@ -114,7 +115,6 @@ fn truncate_base_url(url: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ModelServerInfo, WrapperConnectionStatus}; @@ -128,14 +128,14 @@ mod tests { wrapper_type: "tmux".into(), operator_version: "0.1.30".into(), api_status: RestApiStatus::Stopped, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators: vec![], model_servers: servers, + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -148,6 +148,12 @@ mod tests { }, env_editor: String::new(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::NotMounted, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index dae6f32..5169547 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -663,65 +663,4 @@ impl SetupScreen { } } } - - /// Re-check tmux availability (for [R] key binding) - #[allow(dead_code)] // Will be connected to [R] key handler in app event loop - pub fn recheck_tmux(&mut self) { - self.check_tmux_availability(); - } - - // ─── Kanban Setup Helper Methods ──────────────────────────────────────────── - // These methods are called from app.rs during async credential testing - // and project fetching. They are infrastructure for the full kanban setup flow. - - /// Skip kanban setup entirely - #[allow(dead_code)] - pub fn skip_kanban(&mut self) { - self.kanban_skipped = true; - } - - /// Mark a provider as valid after testing - #[allow(dead_code)] - pub fn mark_provider_valid(&mut self, index: usize) { - use crate::api::providers::kanban::ProviderStatus; - - if let Some(provider) = self.detected_kanban_providers.get_mut(index) { - provider.status = ProviderStatus::Valid; - if !self.valid_kanban_providers.contains(&index) { - self.valid_kanban_providers.push(index); - } - } - } - - /// Mark a provider as failed after testing - #[allow(dead_code)] - pub fn mark_provider_failed(&mut self, index: usize, error: String) { - use crate::api::providers::kanban::ProviderStatus; - - if let Some(provider) = self.detected_kanban_providers.get_mut(index) { - provider.status = ProviderStatus::Failed { error }; - } - } - - /// Set the projects for the current kanban provider - #[allow(dead_code)] - pub fn set_kanban_projects( - &mut self, - projects: Vec, - ) { - self.kanban_projects.set_items(projects); - } - - /// Get the selected kanban project - #[allow(dead_code)] - pub fn selected_kanban_project(&self) -> Option<&crate::api::providers::kanban::ProjectInfo> { - self.kanban_projects.selected_item() - } - - /// Set the preview info for the selected project - #[allow(dead_code)] - pub fn set_kanban_preview(&mut self, issue_types: Vec, member_count: usize) { - self.kanban_issue_types = issue_types; - self.kanban_member_count = member_count; - } } diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index 475f167..12720a6 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -123,9 +123,10 @@ impl StartupTicketOption { } /// Tmux availability detection status -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum TmuxDetectionStatus { /// Not yet checked + #[default] NotChecked, /// Tmux is available with the given version Available { version: String }, @@ -135,17 +136,12 @@ pub enum TmuxDetectionStatus { VersionTooOld { current: String, required: String }, } -impl Default for TmuxDetectionStatus { - fn default() -> Self { - Self::NotChecked - } -} - /// VS Code extension detection status -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // Variants will be used when VS Code extension support is implemented +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[allow(dead_code)] // Placeholder for VS Code extension detection (Phase 2) pub enum VSCodeDetectionStatus { /// Not yet checked + #[default] NotChecked, /// Currently checking connection Checking, @@ -155,12 +151,6 @@ pub enum VSCodeDetectionStatus { NotReachable, } -impl Default for VSCodeDetectionStatus { - fn default() -> Self { - Self::NotChecked - } -} - /// Session wrapper options shown in setup #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionWrapperOption { diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index cf2fe5d..215f436 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -10,12 +10,14 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::backstage::ServerStatus; +use std::path::Path; + +use crate::config::{Config, GitProviderConfig, SessionWrapperType}; use crate::rest::RestApiStatus; use super::sections::{ - ConfigSection, ConnectionsSection, DelegatorSection, GitSection, KanbanSection, LlmSection, - ModelServerSection, + ConfigSection, ConnectionsSection, DelegatorSection, GitSection, IssueTypeSection, + KanbanSection, LlmSection, ManagedProjectsSection, ModelServerSection, }; // --------------------------------------------------------------------------- @@ -71,6 +73,33 @@ impl SectionHealth { SectionHealth::Gray => Color::Gray, } } + + /// Stable lowercase string for serialization (REST / web UI). + pub fn as_str(self) -> &'static str { + match self { + SectionHealth::Green => "green", + SectionHealth::Yellow => "yellow", + SectionHealth::Red => "red", + SectionHealth::Gray => "gray", + } + } +} + +impl SectionId { + /// Stable string id matching the serde rename (REST / web UI / VS Code). + pub fn as_str(self) -> &'static str { + match self { + SectionId::Configuration => "config", + SectionId::Connections => "connections", + SectionId::Kanban => "kanban", + SectionId::LlmTools => "llm", + SectionId::ModelServers => "model-servers", + SectionId::Git => "git", + SectionId::IssueTypes => "issuetypes", + SectionId::Delegators => "delegators", + SectionId::ManagedProjects => "projects", + } + } } /// Declarative section metadata — shared between TUI and `VSCode`. @@ -118,6 +147,22 @@ impl StatusIcon { StatusIcon::None => Span::raw(" "), } } + + /// Stable lowercase icon name for serialization (REST / web UI). + pub fn as_str(self) -> &'static str { + match self { + StatusIcon::Check => "check", + StatusIcon::Cross => "cross", + StatusIcon::Warning => "warning", + StatusIcon::Folder => "folder", + StatusIcon::File => "file", + StatusIcon::Plug => "plug", + StatusIcon::Key => "key", + StatusIcon::Branch => "branch", + StatusIcon::Tool => "tool", + StatusIcon::None => "none", + } + } } // --------------------------------------------------------------------------- @@ -129,6 +174,12 @@ impl StatusIcon { #[allow(dead_code)] pub struct TreeRow { pub section_id: SectionId, + /// Stable, section-scoped row identifier. Used by clients (web UI, VS Code + /// extension) as a tree key and to route row-specific commands without + /// matching on the (mutable) display label. For dynamic rows this is the + /// underlying entity key (issue-type key, project name, delegator name); + /// for static rows it is a fixed slug (e.g. "git-token"). + pub id: String, pub depth: u16, pub label: String, pub description: String, @@ -149,14 +200,16 @@ pub enum StatusAction { EditFile(String), /// Open a URL in the default browser OpenUrl(String), - /// Start the REST API server (without backstage) + /// Start the REST API server StartApi, /// Open Swagger UI for the running API OpenSwagger { port: u16 }, /// Restart the session wrapper connection RestartWrapperConnection, - /// Toggle the web servers (backstage + REST API) - ToggleWebServers, + /// Open the embedded web UI in the default browser + OpenWebUi { port: u16 }, + /// Open the embedded web UI at a specific hash route (e.g. "/config", "/issuetypes") + OpenWebUiAt { port: u16, route: String }, /// Set the global default LLM tool and model SetDefaultLlm { tool_name: String, model: String }, /// Open onboarding for a kanban provider (e.g. "jira", "linear") @@ -169,10 +222,72 @@ pub enum StatusAction { ResetConfig, /// Reload config from disk and restart operator experience ReloadConfig, + /// Toggle `[mcp].http_enabled` (requires API restart to take effect). + ToggleMcpHttp, + /// Generate a client config snippet, write it to + /// `/operator/mcp/.json`, and open it in `$EDITOR`. + /// `client` is one of: "claude-code", "claude-desktop", "cursor", "vscode", "zed". + WriteAndOpenMcpClientConfig { client: String }, + /// Open the operator MCP docs page in the default browser. + OpenMcpDocs, + /// Generate an ACP editor registration snippet, write it to + /// `/operator/acp/.{json,el,toml}`, and open it in + /// `$EDITOR`. `editor` is one of: "zed", "jetbrains", "emacs", "kiro". + WriteAndOpenAcpEditorConfig { editor: String }, + /// Open the operator ACP docs page in the default browser. + OpenAcpDocs, /// No action available for this row None, } +#[allow(dead_code)] +impl StatusAction { + pub fn display_verb(&self) -> Option<&'static str> { + match self { + Self::None => None, + Self::ToggleSection(_) => Some("Toggle"), + Self::OpenDirectory(_) => Some("Open"), + Self::EditFile(_) => Some("Edit"), + Self::OpenUrl(_) => Some("Open"), + Self::StartApi => Some("Start API"), + Self::OpenSwagger { .. } => Some("Swagger"), + Self::RestartWrapperConnection => Some("Restart"), + Self::OpenWebUi { .. } => Some("Web UI"), + Self::OpenWebUiAt { .. } => Some("Web UI"), + Self::SetDefaultLlm { .. } => Some("Set LLM"), + Self::ConfigureKanbanProvider { .. } => Some("Setup"), + Self::ConfigureGitProvider { .. } => Some("Setup"), + Self::RefreshSection(_) => Some("Refresh"), + Self::ResetConfig => Some("Reset"), + Self::ReloadConfig => Some("Reload"), + Self::ToggleMcpHttp => Some("Toggle"), + Self::WriteAndOpenMcpClientConfig { .. } => Some("Generate"), + Self::OpenMcpDocs => Some("Docs"), + Self::WriteAndOpenAcpEditorConfig { .. } => Some("Generate"), + Self::OpenAcpDocs => Some("Docs"), + } + } +} + +/// Hint data for the currently selected status panel row. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct RowHints { + pub primary_verb: Option<&'static str>, + pub special_title: Option<&'static str>, + pub refresh_title: Option<&'static str>, +} + +/// MCP HTTP transport status reflected on the dashboard's MCP row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpHttpStatus { + /// MCP HTTP routes mounted on the REST API server on the given port. + Mounted { port: u16 }, + /// MCP HTTP routes disabled by `[mcp].http_enabled = false`, or the + /// API server itself is not running. + NotMounted, +} + /// Which button was pressed — maps to ABXY gamepad layout. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActionButton { @@ -298,6 +413,23 @@ pub struct ModelServerInfo { pub user_declared: bool, } +/// Information about an active issue type (mirrors `IssueTypeSummary` from the REST DTO). +#[derive(Debug, Clone)] +pub struct IssueTypeInfo { + pub key: String, + pub name: String, + /// "autonomous" or "paired". + pub mode: String, +} + +/// Information about a configured managed project. +#[derive(Debug, Clone)] +pub struct ManagedProjectInfo { + pub name: String, + /// Whether the project directory exists on disk. + pub exists: bool, +} + /// Connection status for the active session wrapper. #[derive(Debug, Clone)] pub enum WrapperConnectionStatus { @@ -410,14 +542,16 @@ pub struct StatusSnapshot { pub wrapper_type: String, pub operator_version: String, pub api_status: RestApiStatus, - pub backstage_status: ServerStatus, - pub backstage_display: bool, pub kanban_providers: Vec, pub llm_tools: Vec, pub default_llm_tool: Option, pub default_llm_model: Option, pub delegators: Vec, pub model_servers: Vec, + /// Active issue types (drives the Issue Types section). + pub issue_types: Vec, + /// Configured managed projects (drives the Managed Projects section). + pub managed_projects: Vec, pub git_provider: Option, pub git_token_set: bool, pub git_branch_format: Option, @@ -428,6 +562,189 @@ pub struct StatusSnapshot { pub env_editor: String, /// Resolved `$VISUAL` value pub env_visual: String, + /// MCP HTTP transport status (mounted on API server, or disabled). + pub mcp_http_status: McpHttpStatus, + /// Whether the descriptor advertises the stdio entrypoint. + pub mcp_stdio_advertised: bool, + /// Currently active MCP SSE sessions on the HTTP transport. + pub mcp_active_sessions: usize, + /// Whether `[acp].stdio_advertised` is true (operator advertises itself + /// as an ACP agent for editor integration). + pub acp_stdio_advertised: bool, + /// Currently active ACP sessions visible to the TUI. v1: always 0 + /// because editor-spawned `operator acp` runs out-of-process. + pub acp_active_sessions: usize, + /// Whether the embedded SPA (ui/) was compiled into the binary via the `embed-ui` feature. + pub embed_ui_available: bool, +} + +impl StatusSnapshot { + /// Returns the API port if the REST server is running. + pub fn api_port(&self) -> Option { + match &self.api_status { + RestApiStatus::Running { port } => Some(*port), + _ => None, + } + } + + /// Build a snapshot from config alone, with default (non-live) runtime fields. + /// + /// Shared by the TUI dashboard — which overrides the runtime fields + /// (`api_status`, wrapper/mcp/acp liveness, editor env) with live state — + /// and the REST `/api/v1/sections` endpoint, which uses the config-derived + /// result as-is. `issue_types` is passed in because the TUI and REST source + /// it from different registries. Everything else here is derived purely from + /// config, so section *health* for the config-gated sections matches across + /// surfaces; only live runtime detail differs. + pub fn from_config(config: &Config, issue_types: Vec) -> StatusSnapshot { + let working_dir = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let config_path = Config::operator_config_path() + .to_string_lossy() + .into_owned(); + let tickets_dir = config.paths.tickets.clone(); + let tickets_dir_exists = Path::new(&tickets_dir).exists(); + + // Kanban providers from jira + linear configs. + let mut kanban_providers: Vec = Vec::new(); + for domain in config.kanban.jira.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: "jira".to_string(), + domain: domain.clone(), + }); + } + for slug in config.kanban.linear.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: "linear".to_string(), + domain: slug.clone(), + }); + } + + let llm_tools: Vec = config + .llm_tools + .detected + .iter() + .map(|t| LlmToolInfo { + name: t.name.clone(), + version: t.version.clone(), + model_aliases: t.model_aliases.clone(), + }) + .collect(); + + let delegators: Vec = config + .delegators + .iter() + .map(|d| DelegatorInfo { + name: d.name.clone(), + display_name: d.display_name.clone(), + llm_tool: d.llm_tool.clone(), + model: d.model.clone(), + yolo: d.launch_config.as_ref().is_some_and(|lc| lc.yolo), + model_server: d.model_server.clone(), + }) + .collect(); + + // Model servers — user-declared plus implicit vendor builtins. + let mut model_servers: Vec = config + .model_servers + .iter() + .map(|s| ModelServerInfo { + name: s.name.clone(), + kind: s.kind.clone(), + base_url: s.base_url.clone(), + display_name: s.display_name.clone(), + user_declared: true, + }) + .collect(); + for tool in ["claude", "codex", "gemini"] { + let implicit = crate::config::implicit_model_server_for_tool(tool); + if !model_servers.iter().any(|s| s.name == implicit.name) { + model_servers.push(ModelServerInfo { + name: implicit.name, + kind: implicit.kind, + base_url: implicit.base_url, + display_name: implicit.display_name, + user_declared: false, + }); + } + } + + let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); + let git_token_set = match config.git.provider { + Some(GitProviderConfig::GitLab) => std::env::var(&config.git.gitlab.token_env).is_ok(), + // GitHub is the default for all other providers (including None). + _ => std::env::var(&config.git.github.token_env).is_ok(), + }; + + // Managed projects — names from config, resolved against the projects base dir. + let projects_base = Path::new(&config.paths.projects); + let managed_projects: Vec = config + .projects + .iter() + .map(|name| ManagedProjectInfo { + name: name.clone(), + exists: projects_base.join(name).exists(), + }) + .collect(); + + // Wrapper connection: config-derived, liveness defaulted to "not yet checked". + let wrapper_connection_status = match config.sessions.wrapper { + SessionWrapperType::Tmux => WrapperConnectionStatus::Tmux { + available: false, + server_running: false, + version: None, + }, + SessionWrapperType::Vscode => WrapperConnectionStatus::Vscode { + webhook_running: false, + port: Some(config.sessions.vscode.webhook_port), + }, + SessionWrapperType::Cmux => WrapperConnectionStatus::Cmux { + binary_available: false, + in_cmux: std::env::var("CMUX_WORKSPACE_ID").is_ok(), + }, + SessionWrapperType::Zellij => WrapperConnectionStatus::Zellij { + binary_available: false, + in_zellij: std::env::var("ZELLIJ").is_ok(), + }, + }; + + let acp_status = crate::acp::AcpAgentServer::from_config(config).status(); + + StatusSnapshot { + working_dir, + config_file_found: true, + config_path, + tickets_dir, + tickets_dir_exists, + wrapper_type: config.sessions.wrapper.display_name().to_string(), + operator_version: env!("CARGO_PKG_VERSION").to_string(), + // Runtime field — callers with live state override this. + api_status: RestApiStatus::Stopped, + kanban_providers, + llm_tools, + default_llm_tool: config.llm_tools.default_tool.clone(), + default_llm_model: config.llm_tools.default_model.clone(), + delegators, + model_servers, + issue_types, + managed_projects, + git_provider, + git_token_set, + git_branch_format: Some(config.git.branch_format.clone()), + git_use_worktrees: config.git.use_worktrees, + update_available_version: None, + wrapper_connection_status, + env_editor: String::new(), + env_visual: String::new(), + mcp_http_status: McpHttpStatus::NotMounted, + mcp_stdio_advertised: config.mcp.stdio_advertised, + mcp_active_sessions: 0, + acp_stdio_advertised: acp_status.is_advertised(), + acp_active_sessions: acp_status.active_sessions(), + embed_ui_available: cfg!(feature = "embed-ui"), + } + } } // --------------------------------------------------------------------------- @@ -488,8 +805,10 @@ impl TreeState { expanded.insert(SectionId::Kanban, false); expanded.insert(SectionId::LlmTools, false); expanded.insert(SectionId::ModelServers, false); - expanded.insert(SectionId::Delegators, false); expanded.insert(SectionId::Git, false); + expanded.insert(SectionId::IssueTypes, false); + expanded.insert(SectionId::Delegators, false); + expanded.insert(SectionId::ManagedProjects, false); Self { expanded, selected: 0, @@ -503,6 +822,78 @@ impl TreeState { // Status panel (orchestrator) // --------------------------------------------------------------------------- +/// The canonical, ordered list of status sections. +/// +/// This order is the single source of truth, matching the `SectionId` enum and +/// the VS Code extension's `allSections`. Used by both the TUI status panel and +/// the REST `/api/v1/sections` endpoint (via [`build_section_dtos`]). +pub fn all_sections() -> Vec> { + vec![ + Box::new(ConfigSection), + Box::new(ConnectionsSection), + Box::new(KanbanSection), + Box::new(LlmSection), + Box::new(ModelServerSection), + Box::new(GitSection), + Box::new(IssueTypeSection), + Box::new(DelegatorSection), + Box::new(ManagedProjectsSection), + ] +} + +/// Build the REST `SectionDto` list from a snapshot by running every canonical +/// section. Lives here (not in `rest`) because the section logic is ui-layer; +/// the binary injects this into `rest` via `register_section_provider` so the +/// web UI's Status page renders the same sections the TUI and VS Code show. +/// +/// Returns all sections with a `met` flag (computed from prerequisite health) +/// rather than hiding unmet ones, so the web UI can render every section. +pub fn build_section_dtos(snapshot: &StatusSnapshot) -> Vec { + use crate::rest::dto::{SectionDto, SectionRowDto}; + + let sections = all_sections(); + + // First pass: each section's health, for prerequisite checks. + let health_by_id: HashMap = sections + .iter() + .map(|s| (s.section_id(), s.health(snapshot))) + .collect(); + + sections + .iter() + .map(|s| { + let met = s + .prerequisites() + .iter() + .all(|p| health_by_id.get(p) == Some(&SectionHealth::Green)); + SectionDto { + id: s.section_id().as_str().to_string(), + label: s.label().to_string(), + health: s.health(snapshot).as_str().to_string(), + description: s.description(snapshot), + prerequisites: s + .prerequisites() + .iter() + .map(|p| p.as_str().to_string()) + .collect(), + met, + children: s + .children(snapshot) + .into_iter() + .map(|r| SectionRowDto { + id: r.id, + depth: r.depth, + label: r.label, + description: r.description, + icon: r.icon.as_str().to_string(), + health: r.health.as_str().to_string(), + }) + .collect(), + } + }) + .collect() +} + /// The status panel widget — a collapsible tree with progressive disclosure. pub struct StatusPanel { pub tree_state: TreeState, @@ -512,19 +903,10 @@ pub struct StatusPanel { impl StatusPanel { pub fn new(title: String) -> Self { - let sections: Vec> = vec![ - Box::new(ConfigSection), - Box::new(ConnectionsSection), - Box::new(KanbanSection), - Box::new(LlmSection), - Box::new(ModelServerSection), - Box::new(DelegatorSection), - Box::new(GitSection), - ]; Self { tree_state: TreeState::new(), title, - sections, + sections: all_sections(), } } @@ -568,14 +950,34 @@ impl StatusPanel { let health = section.health(snapshot); // Header row + let web_route = web_ui_route_for(section.section_id()); + let special = if let (Some(route), Some(port)) = (web_route, snapshot.api_port()) { + StatusAction::OpenWebUiAt { + port, + route: route.to_string(), + } + } else { + StatusAction::None + }; rows.push(TreeRow { section_id: section.section_id(), + id: section.section_id().as_str().to_string(), depth: 0, label: section.label().to_string(), description: section.description(snapshot), icon: StatusIcon::None, is_header: true, - actions: ActionSet::primary(StatusAction::ToggleSection(section.section_id())), + actions: ActionSet { + primary: StatusAction::ToggleSection(section.section_id()), + back: StatusAction::None, + special, + special_meta: web_route.map(|_| ActionMeta { + title: "Web", + tooltip: "Open this section in the web UI", + }), + refresh: StatusAction::None, + refresh_meta: None, + }, health, }); @@ -685,6 +1087,38 @@ impl StatusPanel { self.flatten(snapshot).len() } + /// Get hint data for the currently selected row, if any. + #[allow(dead_code)] + pub fn current_row_hints(&self, snapshot: &StatusSnapshot) -> Option { + let rows = self.flatten(snapshot); + let row = rows.get(self.tree_state.selected)?; + Some(RowHints { + primary_verb: row.actions.primary.display_verb(), + special_title: if row.actions.special == StatusAction::None { + None + } else { + Some( + row.actions + .special_meta + .as_ref() + .map(|m| m.title) + .unwrap_or("Special"), + ) + }, + refresh_title: if row.actions.refresh == StatusAction::None { + None + } else { + Some( + row.actions + .refresh_meta + .as_ref() + .map(|m| m.title) + .unwrap_or("Refresh"), + ) + }, + }) + } + /// Render the status panel into the given area. pub fn render(&self, frame: &mut Frame, area: Rect, focused: bool, snapshot: &StatusSnapshot) { let rows = self.flatten(snapshot); @@ -831,6 +1265,20 @@ impl StatusPanel { } // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Deep-link routing: maps section IDs to hash routes in the embedded web UI. +// --------------------------------------------------------------------------- + +fn web_ui_route_for(section: SectionId) -> Option<&'static str> { + match section { + SectionId::Configuration => Some("/config"), + SectionId::Kanban => Some("/config"), + SectionId::IssueTypes => Some("/issuetypes"), + SectionId::ManagedProjects => Some("/config"), + _ => None, + } +} + // Tests // --------------------------------------------------------------------------- @@ -838,6 +1286,42 @@ impl StatusPanel { mod tests { use super::*; + #[test] + fn test_build_section_dtos_returns_all_nine_in_canonical_order() { + let snapshot = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + let dtos = build_section_dtos(&snapshot); + let ids: Vec<&str> = dtos.iter().map(|d| d.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + "config", + "connections", + "kanban", + "llm", + "model-servers", + "git", + "issuetypes", + "delegators", + "projects", + ] + ); + } + + #[test] + fn test_build_section_dtos_sets_met_from_prerequisites() { + let snapshot = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + let dtos = build_section_dtos(&snapshot); + // Configuration has no prerequisites, so it is always met. + let config = dtos.iter().find(|d| d.id == "config").unwrap(); + assert!(config.met); + assert!(config.prerequisites.is_empty()); + // Managed Projects requires Git; on a default config Git is not Green, + // so its prerequisites are not met. + let projects = dtos.iter().find(|d| d.id == "projects").unwrap(); + assert_eq!(projects.prerequisites, vec!["git"]); + assert!(!projects.met); + } + fn test_snapshot() -> StatusSnapshot { StatusSnapshot { working_dir: "/home/user/project".into(), @@ -848,10 +1332,6 @@ mod tests { wrapper_type: "tmux".into(), operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 3100 }, - backstage_status: ServerStatus::Running { - port: 7007, - pid: 1234, - }, kanban_providers: vec![KanbanProviderInfo { provider_type: "Linear".into(), domain: "myteam.linear.app".into(), @@ -872,6 +1352,8 @@ mod tests { model_server: None, }], model_servers: Vec::new(), + issue_types: Vec::new(), + managed_projects: Vec::new(), git_provider: Some("GitHub".into()), git_token_set: true, git_branch_format: Some("feature/{ticket}".into()), @@ -884,7 +1366,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), - backstage_display: false, + mcp_http_status: McpHttpStatus::Mounted { port: 3100 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } @@ -1321,6 +1808,129 @@ mod tests { assert_ne!(config.actions.refresh, StatusAction::None); } + #[test] + fn test_display_verb_returns_none_for_none() { + assert_eq!(StatusAction::None.display_verb(), None); + } + + #[test] + fn test_display_verb_returns_verb_for_each_variant() { + let cases: Vec<(StatusAction, &str)> = vec![ + ( + StatusAction::ToggleSection(SectionId::Configuration), + "Toggle", + ), + (StatusAction::OpenDirectory("/tmp".into()), "Open"), + (StatusAction::EditFile("config.toml".into()), "Edit"), + (StatusAction::OpenUrl("https://example.com".into()), "Open"), + (StatusAction::StartApi, "Start API"), + (StatusAction::OpenSwagger { port: 3100 }, "Swagger"), + (StatusAction::RestartWrapperConnection, "Restart"), + (StatusAction::OpenWebUi { port: 7007 }, "Web UI"), + ( + StatusAction::OpenWebUiAt { + port: 7007, + route: "/config".into(), + }, + "Web UI", + ), + ( + StatusAction::SetDefaultLlm { + tool_name: "claude".into(), + model: "opus".into(), + }, + "Set LLM", + ), + ( + StatusAction::ConfigureKanbanProvider { + provider: "jira".into(), + }, + "Setup", + ), + ( + StatusAction::ConfigureGitProvider { + provider: "github".into(), + }, + "Setup", + ), + ( + StatusAction::RefreshSection(SectionId::Connections), + "Refresh", + ), + (StatusAction::ResetConfig, "Reset"), + (StatusAction::ReloadConfig, "Reload"), + (StatusAction::ToggleMcpHttp, "Toggle"), + ( + StatusAction::WriteAndOpenMcpClientConfig { + client: "claude-code".into(), + }, + "Generate", + ), + (StatusAction::OpenMcpDocs, "Docs"), + ( + StatusAction::WriteAndOpenAcpEditorConfig { + editor: "zed".into(), + }, + "Generate", + ), + (StatusAction::OpenAcpDocs, "Docs"), + ]; + + for (action, expected) in cases { + assert_eq!( + action.display_verb(), + Some(expected), + "display_verb() for {action:?} should be {expected:?}" + ); + } + } + + #[test] + fn test_current_row_hints_returns_none_for_empty_panel() { + let mut snap = test_snapshot(); + snap.config_file_found = false; + let mut panel = StatusPanel::new("Status".into()); + panel.tree_state.selected = 9999; + let hints = panel.current_row_hints(&snap); + assert!(hints.is_none()); + } + + #[test] + fn test_current_row_hints_primary_only_row() { + let snap = test_snapshot(); + + let mut panel = StatusPanel::new("Status".into()); + panel.tree_state.selected = 1; + let hints = panel.current_row_hints(&snap).unwrap(); + assert_eq!(hints.primary_verb, Some("Open")); + assert!(hints.special_title.is_none()); + assert!(hints.refresh_title.is_none()); + } + + #[test] + fn test_current_row_hints_with_special_and_refresh() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Config row (index 2) has special=ResetConfig and refresh=ReloadConfig + panel.tree_state.selected = 2; + let hints = panel.current_row_hints(&snap).unwrap(); + assert_eq!(hints.primary_verb, Some("Edit")); + assert!(hints.special_title.is_some()); + assert!(hints.refresh_title.is_some()); + } + + #[test] + fn test_current_row_hints_header_row() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Header row (index 0) has primary=ToggleSection + panel.tree_state.selected = 0; + let hints = panel.current_row_hints(&snap).unwrap(); + assert_eq!(hints.primary_verb, Some("Toggle")); + } + #[test] fn test_refreshing_set_tracks_state() { let mut state = TreeState::new(); diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs new file mode 100644 index 0000000..ac9fc9d --- /dev/null +++ b/src/workflow_gen/command.rs @@ -0,0 +1,110 @@ +//! The shared "export workflow for a ticket" operation. +//! +//! This is the single code path every surface goes through: the CLI and TUI +//! call it directly in-process, and the REST handler calls it after resolving +//! the ticket — so the web UI and VS Code extension reach the same logic over +//! HTTP. Ticket resolution (filesystem/queue I/O) stays at the edges; this +//! function takes an already-resolved ticket plus the issue-type registry. + +use anyhow::{anyhow, Result}; + +use super::export_workflow; +use crate::issuetypes::IssueTypeRegistry; +use crate::pr_config::PrConfig; +use crate::queue::Ticket; + +/// The result of exporting a ticket to a Claude dynamic workflow. +#[derive(Debug, Clone)] +pub struct ExportedWorkflow { + /// The ticket the workflow was generated from. + pub ticket_id: String, + /// The issue type key that supplied the step structure. + pub issuetype_key: String, + /// A suggested filename for writing the workflow (`.workflow.js`). + pub suggested_filename: String, + /// The generated `.js` workflow source. + pub contents: String, +} + +/// Resolve `ticket`'s issue type from `registry` and render it into a Claude +/// dynamic workflow. Errors if the ticket's type has no matching issue type. +pub fn export_workflow_for_ticket( + ticket: &Ticket, + registry: &IssueTypeRegistry, + pr_config: Option<&PrConfig>, +) -> Result { + let issuetype = registry.get(&ticket.ticket_type).ok_or_else(|| { + anyhow!( + "No issue type '{}' registered for ticket {}", + ticket.ticket_type, + ticket.id + ) + })?; + + let contents = export_workflow(ticket, issuetype, pr_config)?; + + Ok(ExportedWorkflow { + ticket_id: ticket.id.clone(), + issuetype_key: issuetype.key.clone(), + suggested_filename: format!("{}.workflow.js", ticket.id), + contents, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn registry() -> IssueTypeRegistry { + let mut r = IssueTypeRegistry::new(); + r.load_builtins().expect("load builtins"); + r + } + + fn ticket(ticket_type: &str) -> Ticket { + Ticket { + filename: "20241221-1430-FEAT-gamesvc-test.md".to_string(), + filepath: "/test/path".to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: ticket_type.to_string(), + project: "gamesvc".to_string(), + id: "FEAT-1234".to_string(), + summary: "Add pagination".to_string(), + priority: "P2-medium".to_string(), + status: "queued".to_string(), + step: String::new(), + content: "body".to_string(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + } + } + + #[test] + fn exports_known_ticket_type_into_workflow() { + let exported = export_workflow_for_ticket(&ticket("FEAT"), ®istry(), None).unwrap(); + assert_eq!(exported.ticket_id, "FEAT-1234"); + assert_eq!(exported.issuetype_key, "FEAT"); + assert_eq!(exported.suggested_filename, "FEAT-1234.workflow.js"); + assert!( + exported.contents.contains("agent("), + "workflow body missing:\n{}", + exported.contents + ); + assert!( + exported.contents.contains("FEAT-1234"), + "ticket id not interpolated" + ); + } + + #[test] + fn errors_when_issuetype_unknown() { + let result = export_workflow_for_ticket(&ticket("NOPE"), ®istry(), None); + assert!(result.is_err(), "unknown issuetype should error"); + } +} diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs new file mode 100644 index 0000000..146a001 --- /dev/null +++ b/src/workflow_gen/export.rs @@ -0,0 +1,402 @@ +//! Orchestration: render a ticket against its issuetype into a Claude +//! dynamic-workflow `.js` source string. + +use anyhow::{Context, Result}; +use handlebars::Handlebars; +use serde_json::Value; + +use super::js; +use super::step_map; +use super::GAP_MARKER; +use crate::issuetypes::IssueType; +use crate::pr_config::PrConfig; +use crate::queue::Ticket; +use crate::steps::manager::StepManager; +use crate::templates::schema::{RagSource, ReviewType, StepSchema, StepTypeTag}; + +/// Render `ticket` against `issuetype` into a concrete Claude dynamic-workflow +/// `.js` source string. +/// +/// Deterministic: the output never contains wall-clock, `Date.now`, or +/// `Math.random`, so the same inputs always produce byte-identical output. +pub fn export_workflow( + ticket: &Ticket, + issuetype: &IssueType, + pr_config: Option<&PrConfig>, +) -> Result { + // Reuse the exact variable surface a step prompt sees at launch time. + let ctx = StepManager::build_ticket_context(ticket, pr_config); + + let mut hbs = Handlebars::new(); + hbs.set_strict_mode(false); + hbs.register_escape_fn(handlebars::no_escape); + + let steps = ordered_steps(issuetype); + + let mut out = String::new(); + out.push_str(&provenance_header(ticket, issuetype)); + out.push_str(&meta_block(ticket, issuetype, &steps)); + out.push_str("export default async function () {\n"); + for step in &steps { + out.push_str(&render_step(&hbs, &ctx, step)?); + } + out.push_str("}\n"); + Ok(out) +} + +/// Order steps by following the `next_step` chain from the first step, then +/// appending any steps not reachable that way (in declaration order). +fn ordered_steps(it: &IssueType) -> Vec<&StepSchema> { + let mut order: Vec<&StepSchema> = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + let mut current = it.first_step(); + while let Some(step) = current { + if !seen.insert(step.name.as_str()) { + break; + } + order.push(step); + current = step.next_step.as_deref().and_then(|n| it.get_step(n)); + } + for step in &it.steps { + if seen.insert(step.name.as_str()) { + order.push(step); + } + } + order +} + +fn render(hbs: &Handlebars, template: &str, ctx: &Value) -> Result { + hbs.render_template(template, ctx) + .context("failed to render step prompt for workflow export") +} + +fn provenance_header(ticket: &Ticket, it: &IssueType) -> String { + format!( + "/* ───────────── OPERATOR PROVENANCE (audit) ─────────────\n\ + \x20* Generated by: operator workflow export\n\ + \x20* Ticket: {id} ({ttype}, project {project})\n\ + \x20* Issuetype: {key} — {name}\n\ + \x20* NOTE: autonomous flattening of an operator issuetype. Human review_type/on_reject\n\ + \x20* gates are emitted as judge-agent loops (see {gap} markers below). One operator\n\ + \x20* step = a full session; one agent() call = one background subagent.\n\ + \x20* ──────────────────────────────────────────────────────── */\n", + id = js::comment_safe(&ticket.id), + ttype = js::comment_safe(&ticket.ticket_type), + project = js::comment_safe(&ticket.project), + key = js::comment_safe(&it.key), + name = js::comment_safe(&it.name), + gap = GAP_MARKER, + ) +} + +fn meta_block(ticket: &Ticket, it: &IssueType, steps: &[&StepSchema]) -> String { + let name = if ticket.summary.is_empty() { + format!("{} — {}", ticket.id, it.name) + } else { + format!("{} — {}", ticket.id, ticket.summary) + }; + let description = format!("{}: {}", it.name, it.description); + + let mut phases = String::new(); + for step in steps { + phases.push_str(&format!( + " {{ title: {} }},\n", + js::quote(step.display_name()) + )); + } + + format!( + "export const meta = {{\n name: {name},\n description: {desc},\n phases: [\n{phases} ],\n}};\n\n", + name = js::quote(&name), + desc = js::quote(&description), + phases = phases, + ) +} + +fn render_step(hbs: &Handlebars, ctx: &Value, step: &StepSchema) -> Result { + let var = step_map::result_var(&step.name); + let prompt = render(hbs, &step.prompt, ctx)?; + let has_review = step.requires_review() || step.on_reject.is_some(); + + let mut s = String::new(); + s.push_str(&format!( + " await phase({});\n", + js::quote(step.display_name()) + )); + + match step.step_type { + StepTypeTag::Task | StepTypeTag::Delegator => { + // A delegator step may prepend prompt flavor and pin a model. + let mut effective_prompt = prompt.clone(); + let mut model: Option = step.agent.clone(); + if step.step_type == StepTypeTag::Delegator { + if let Some(cfg) = &step.delegator_config { + model = Some(cfg.delegator.clone()); + if let Some(flavor) = &cfg.prompt_flavor { + let rendered_flavor = render(hbs, flavor, ctx)?; + effective_prompt = format!("{rendered_flavor}\n\n{prompt}"); + } + } + } + if !step.allowed_tools.is_empty() { + s.push_str(&format!( + " // allowed tools: {}\n", + step.allowed_tools.join(", ") + )); + } + if has_review { + s.push_str(&judge_loop(step, &var, &effective_prompt, model.as_deref())); + } else { + s.push_str(&agent_call( + &var, + &effective_prompt, + &step.name, + model.as_deref(), + )); + } + } + StepTypeTag::Classifier => { + let schema = if let Some(explicit) = &step.json_schema { + explicit.clone() + } else if let Some(cfg) = &step.classifier_config { + step_map::classifier_schema(cfg) + } else { + serde_json::json!({ "type": "object" }) + }; + let schema_str = serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string()); + s.push_str(&format!( + " const {var} = await agent({prompt}, {{ label: {label}, schema: {schema} }});\n", + prompt = js::quote(&prompt), + label = js::quote(&step.name), + schema = schema_str, + )); + if has_review { + s.push_str(&review_comment(step)); + } + } + StepTypeTag::MultiModel => { + s.push_str(&render_multi_model(hbs, ctx, step, &var, &prompt)?); + } + StepTypeTag::MultiPrompt => { + s.push_str(&render_multi_prompt(hbs, ctx, step, &var)?); + } + StepTypeTag::Matrixed => { + s.push_str(&render_matrixed(hbs, ctx, step, &var)?); + } + StepTypeTag::Rag => { + s.push_str(&format!( + " // {GAP_MARKER}: rag step — the workflow sandbox has no filesystem; context sources must be gathered by the agent.\n" + )); + if let Some(cfg) = &step.rag_config { + let srcs: Vec = cfg.sources.iter().map(describe_rag_source).collect(); + if !srcs.is_empty() { + s.push_str(&format!(" // context sources: {}\n", srcs.join(", "))); + } + } + s.push_str(&agent_call(&var, &prompt, &step.name, None)); + } + StepTypeTag::Mcp => { + s.push_str(&format!( + " // {GAP_MARKER}: mcp step — the workflow sandbox cannot guarantee MCP tool availability.\n" + )); + if let Some(cfg) = &step.mcp_config { + let tools: Vec = cfg + .required_tools + .iter() + .map(|t| match &t.tool { + Some(tool) => format!("{}/{}", t.server, tool), + None => t.server.clone(), + }) + .collect(); + if !tools.is_empty() { + s.push_str(&format!(" // required tools: {}\n", tools.join(", "))); + } + } + s.push_str(&agent_call(&var, &prompt, &step.name, None)); + } + } + + s.push('\n'); + Ok(s) +} + +/// A single `const r_x = await agent("...", { label, [model] });` line. +fn agent_call(var: &str, prompt: &str, label: &str, model: Option<&str>) -> String { + let mut opts = format!("label: {}", js::quote(label)); + if let Some(m) = model { + opts.push_str(&format!(", model: {}", js::quote(m))); + } + format!( + " const {var} = await agent({prompt}, {{ {opts} }});\n", + prompt = js::quote(prompt), + ) +} + +fn review_label(review: &ReviewType) -> &'static str { + match review { + ReviewType::None => "output", + ReviewType::Plan => "plan", + ReviewType::Visual => "visual", + ReviewType::Pr => "pr", + } +} + +/// Autonomous judge-agent retry loop standing in for a human review gate. +fn judge_loop(step: &StepSchema, var: &str, prompt: &str, model: Option<&str>) -> String { + let review = review_label(&step.review_type); + let goto = step + .on_reject + .as_ref() + .map(|r| r.goto_step.clone()) + .unwrap_or_else(|| step.name.clone()); + + let mut opts = format!("label: {}", js::quote(&step.name)); + if let Some(m) = model { + opts.push_str(&format!(", model: {}", js::quote(m))); + } + + format!( + " // {gap}: step {name} had human review_type={review}; a human gate cannot run mid-workflow.\n\ + \x20 // Emitted as an autonomous judge loop (max 3 attempts); original on_reject -> goto {goto}.\n\ + \x20 let {var};\n\ + \x20 let {var}_attempt = 0;\n\ + \x20 do {{\n\ + \x20 {var} = await agent({prompt}, {{ {opts} }});\n\ + \x20 var {var}_verdict = await agent({judge} + JSON.stringify({var}), {{ label: {jlabel} }});\n\ + \x20 }} while (String({var}_verdict).includes(\"REVISE\") && ++{var}_attempt < 3);\n\ + \x20 log({logmsg});\n", + gap = GAP_MARKER, + name = js::quote(&step.name), + review = js::quote(review), + goto = js::quote(&goto), + var = var, + prompt = js::quote(prompt), + opts = opts, + judge = js::quote( + "Review the previous step's output against its acceptance criteria. Reply with PASS or REVISE and the reasons.\n\nOutput:\n" + ), + jlabel = js::quote(&format!("{}-review", step.name)), + logmsg = js::quote(&format!( + "{GAP_MARKER}: step '{}' review was autonomous; a human gate (review_type={review}, on_reject->goto {goto}) was flattened.", + step.name + )), + ) +} + +fn review_comment(step: &StepSchema) -> String { + format!( + " // {GAP_MARKER}: step '{}' carries review_type={}; verify its output before relying on it.\n", + step.name, + review_label(&step.review_type), + ) +} + +fn render_multi_model( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + var: &str, + prompt: &str, +) -> Result { + let Some(cfg) = &step.multi_model_config else { + return Ok(agent_call(var, prompt, &step.name, None)); + }; + let mut branches = String::new(); + for d in &cfg.delegators { + branches.push_str(&format!( + " () => agent({prompt}, {{ model: {model} }}),\n", + prompt = js::quote(prompt), + model = js::quote(d), + )); + } + let vote_prompt = match &cfg.voting_prompt { + Some(p) => render(hbs, p, ctx)?, + None => { + "Review the candidate answers and select or synthesize the single best one.".to_string() + } + }; + let strategy = serde_json::to_string(&cfg.voting_strategy).unwrap_or_default(); + Ok(format!( + " // multi-model fan-out + vote (voting_strategy: {strategy})\n\ + \x20 const {var} = await parallel([\n{branches} ]).then(answers =>\n\ + \x20 agent({vote} + JSON.stringify(answers), {{ label: {label} }}));\n", + var = var, + branches = branches, + vote = js::quote(&format!("{vote_prompt}\n\nCandidate answers:\n")), + label = js::quote(&format!("{}-vote", step.name)), + )) +} + +fn render_multi_prompt( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + var: &str, +) -> Result { + let Some(cfg) = &step.multi_prompt_config else { + let prompt = render(hbs, &step.prompt, ctx)?; + return Ok(agent_call(var, &prompt, &step.name, None)); + }; + let mut branches = String::new(); + for variation in &cfg.prompt_variations { + let rendered = render(hbs, variation, ctx)?; + branches.push_str(&format!(" () => agent({}),\n", js::quote(&rendered))); + } + let select_prompt = match &cfg.selection_prompt { + Some(p) => render(hbs, p, ctx)?, + None => "Review the candidate outputs and select the single best one.".to_string(), + }; + let strategy = serde_json::to_string(&cfg.selection_strategy).unwrap_or_default(); + Ok(format!( + " // multi-prompt variations + select (selection_strategy: {strategy})\n\ + \x20 const {var} = await parallel([\n{branches} ]).then(outputs =>\n\ + \x20 agent({select} + JSON.stringify(outputs), {{ label: {label} }}));\n", + var = var, + branches = branches, + select = js::quote(&format!("{select_prompt}\n\nCandidate outputs:\n")), + label = js::quote(&format!("{}-select", step.name)), + )) +} + +fn render_matrixed(hbs: &Handlebars, ctx: &Value, step: &StepSchema, var: &str) -> Result { + let Some(cfg) = &step.matrixed_config else { + let prompt = render(hbs, &step.prompt, ctx)?; + return Ok(agent_call(var, &prompt, &step.name, None)); + }; + let rendered_variations: Vec = cfg + .prompt_variations + .iter() + .map(|v| render(hbs, v, ctx)) + .collect::>()?; + + let format_str = serde_json::to_string(&cfg.output_format).unwrap_or_default(); + let mut rows = String::new(); + for d in &cfg.delegators { + let mut cells = String::new(); + for v in &rendered_variations { + cells.push_str(&format!( + " () => agent({prompt}, {{ model: {model} }}),\n", + prompt = js::quote(v), + model = js::quote(d), + )); + } + rows.push_str(&format!(" () => parallel([\n{cells} ]),\n")); + } + Ok(format!( + " // matrixed {n}x{m} (delegators x prompt variations, output_format: {format_str})\n\ + \x20 const {var} = await parallel([\n{rows} ]);\n", + n = cfg.delegators.len(), + m = rendered_variations.len(), + var = var, + rows = rows, + )) +} + +fn describe_rag_source(src: &RagSource) -> String { + match src { + RagSource::Glob { pattern } => format!("glob:{pattern}"), + RagSource::File { path } => format!("file:{path}"), + RagSource::Mcp { server, tool, .. } => format!("mcp:{server}/{tool}"), + } +} diff --git a/src/workflow_gen/js.rs b/src/workflow_gen/js.rs new file mode 100644 index 0000000..b2a7325 --- /dev/null +++ b/src/workflow_gen/js.rs @@ -0,0 +1,35 @@ +//! Helpers for emitting safe `JavaScript` source. + +/// Escape a string for embedding inside a double-quoted JS string literal. +/// +/// Returns the escaped *content* only (no surrounding quotes). Handles the +/// characters that would otherwise terminate the literal or change its meaning: +/// backslash, double quote, and the common control characters. `${` and +/// backticks need no escaping because we only ever emit double-quoted literals. +pub fn escape_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + // Strip other C0 control chars that have no business in a literal. + c if (c as u32) < 0x20 => out.push(' '), + c => out.push(c), + } + } + out +} + +/// Wrap a string as a complete double-quoted JS string literal. +pub fn quote(s: &str) -> String { + format!("\"{}\"", escape_str(s)) +} + +/// Make a string safe to embed inside a `/* ... */` block comment by defusing +/// any sequence that would close the comment early. +pub fn comment_safe(s: &str) -> String { + s.replace("*/", "* /") +} diff --git a/src/workflow_gen/mod.rs b/src/workflow_gen/mod.rs new file mode 100644 index 0000000..9f8ba5b --- /dev/null +++ b/src/workflow_gen/mod.rs @@ -0,0 +1,234 @@ +#![allow(dead_code)] // Public surface consumed by the `workflow export` CLI command. + +//! Export an operator ticket + issuetype to a Claude Code "dynamic workflow" +//! `.js` file. +//! +//! This is **export-only**: operator never parses `.js` back. The unit of +//! export is a ticket *and* its issuetype together — the issuetype supplies the +//! step structure, the ticket supplies the concrete field values. Rendering +//! them produces a workflow specialized to that exact ticket. +//! +//! The mapping is a lossy, autonomous *flattening*, not an equivalence: +//! human review gates (`review_type` / `on_reject`) have no workflow analog +//! (workflows forbid mid-run human input), so they are emitted as autonomous +//! judge-agent retry loops marked with `OPERATOR-GAP`. `rag`/`mcp` steps depend +//! on capabilities the workflow sandbox lacks and are likewise marked. + +mod command; +mod export; +pub mod js; +mod step_map; + +pub use command::{export_workflow_for_ticket, ExportedWorkflow}; +pub use export::export_workflow; + +/// Marker emitted into generated workflows wherever an operator concept could +/// not be faithfully represented. +pub const GAP_MARKER: &str = "OPERATOR-GAP"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::issuetypes::IssueType; + use crate::queue::Ticket; + + /// Build a ticket with the given id/project/summary; other fields fixed. + fn ticket(id: &str, project: &str, summary: &str) -> Ticket { + Ticket { + filename: "20241221-1430-FEAT-proj-test.md".to_string(), + filepath: "/test/path".to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: "FEAT".to_string(), + project: project.to_string(), + id: id.to_string(), + summary: summary.to_string(), + priority: "P2-medium".to_string(), + status: "queued".to_string(), + step: String::new(), + content: "body".to_string(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + } + } + + /// Build an issuetype with the given steps JSON array. + fn issuetype(steps_json: &str) -> IssueType { + let json = format!( + r#"{{ + "key": "FEAT", + "name": "Feature", + "description": "A new feature", + "mode": "autonomous", + "glyph": "*", + "fields": [], + "steps": {steps_json} + }}"# + ); + IssueType::from_json(&json).expect("valid issuetype json") + } + + fn export(steps_json: &str) -> String { + let it = issuetype(steps_json); + let tk = ticket("FEAT-1234", "gamesvc", "Add pagination"); + export_workflow(&tk, &it, None).expect("export ok") + } + + #[test] + fn js_escape_handles_quotes_backslashes_and_newlines() { + assert_eq!(js::escape_str("a\"b\\c\nd\te"), "a\\\"b\\\\c\\nd\\te"); + } + + #[test] + fn task_step_emits_agent_with_interpolated_prompt_and_phase() { + let out = export( + r#"[{"name":"plan","display_name":"Planning","outputs":["plan"], + "prompt":"Work on {{ id }} in {{ project }}"}]"#, + ); + assert!( + out.contains(r#"agent("Work on FEAT-1234 in gamesvc""#), + "interpolated agent call missing:\n{out}" + ); + assert!( + out.contains(r#"phase("Planning")"#), + "phase missing:\n{out}" + ); + assert!(!out.contains("{{"), "unrendered handlebars left in:\n{out}"); + } + + #[test] + fn classifier_step_emits_schema_opt() { + let out = export( + r#"[{"name":"triage","type":"classifier","outputs":["report"], + "prompt":"Is this a bug?","classifier_config":{"output_type":"boolean"}}]"#, + ); + assert!(out.contains("schema:"), "classifier schema missing:\n{out}"); + } + + #[test] + fn multi_model_step_emits_parallel_then_vote() { + let out = export( + r#"[{"name":"consensus","type":"multi_model","outputs":["code"], + "prompt":"Solve {{ id }}", + "multi_model_config":{"delegators":["opus","sonnet"],"voting_strategy":"majority"}}]"#, + ); + assert!(out.contains("parallel(["), "parallel missing:\n{out}"); + assert!(out.contains(".then("), ".then missing:\n{out}"); + } + + #[test] + fn multi_prompt_step_emits_parallel_and_select() { + let out = export( + r#"[{"name":"explore","type":"multi_prompt","outputs":["plan"], + "prompt":"base", + "multi_prompt_config":{"prompt_variations":["angle A","angle B"],"selection_strategy":"model_choice"}}]"#, + ); + assert!(out.contains("parallel(["), "parallel missing:\n{out}"); + assert!( + out.contains("angle A") && out.contains("angle B"), + "variations missing:\n{out}" + ); + } + + #[test] + fn matrixed_step_emits_nested_parallel() { + let out = export( + r#"[{"name":"matrix","type":"matrixed","outputs":["code"], + "prompt":"base", + "matrixed_config":{"delegators":["opus","sonnet"],"prompt_variations":["v1","v2"],"output_format":"structured"}}]"#, + ); + // Nested parallel: at least two occurrences. + assert!( + out.matches("parallel(").count() >= 2, + "expected nested parallel:\n{out}" + ); + } + + #[test] + fn rag_and_mcp_steps_emit_gap_markers() { + let rag = export( + r#"[{"name":"gather","type":"rag","outputs":["report"],"prompt":"summarize", + "rag_config":{"sources":[{"type":"glob","pattern":"src/**/*.rs"}]}}]"#, + ); + assert!(rag.contains(GAP_MARKER), "rag gap marker missing:\n{rag}"); + + let mcp = export( + r#"[{"name":"lookup","type":"mcp","outputs":["report"],"prompt":"check", + "mcp_config":{"required_tools":[{"server":"jira","tool":"search"}]}}]"#, + ); + assert!(mcp.contains(GAP_MARKER), "mcp gap marker missing:\n{mcp}"); + } + + #[test] + fn review_gate_emits_judge_loop_and_gap_marker() { + let out = export( + r#"[{"name":"plan","outputs":["plan"],"prompt":"make a plan", + "review_type":"plan", + "on_reject":{"goto_step":"plan","prompt":"redo"}, + "next_step":"ship"}, + {"name":"ship","outputs":["pr"],"prompt":"ship it"}]"#, + ); + assert!(out.contains(GAP_MARKER), "gap marker missing:\n{out}"); + assert!( + out.contains("do {") && out.contains("} while"), + "judge loop missing:\n{out}" + ); + assert!(out.contains("log("), "log marker missing:\n{out}"); + } + + #[test] + fn output_has_provenance_header_and_meta() { + let out = export(r#"[{"name":"plan","outputs":["plan"],"prompt":"go"}]"#); + assert!( + out.contains("OPERATOR PROVENANCE"), + "provenance header missing:\n{out}" + ); + assert!( + out.contains("FEAT-1234"), + "ticket id missing from provenance:\n{out}" + ); + assert!( + out.contains("export const meta"), + "meta block missing:\n{out}" + ); + assert!( + out.contains("export default async function"), + "entrypoint missing:\n{out}" + ); + } + + #[test] + fn output_is_deterministic_and_has_no_wallclock() { + let it = issuetype(r#"[{"name":"plan","outputs":["plan"],"prompt":"go {{ id }}"}]"#); + let tk = ticket("FEAT-1234", "gamesvc", "x"); + let a = export_workflow(&tk, &it, None).unwrap(); + let b = export_workflow(&tk, &it, None).unwrap(); + assert_eq!(a, b, "export is not deterministic"); + assert!(!a.contains("Date.now"), "must not emit Date.now"); + assert!(!a.contains("Math.random"), "must not emit Math.random"); + } + + #[test] + fn prompt_with_special_chars_is_escaped_into_valid_literal() { + let out = export( + r#"[{"name":"plan","outputs":["plan"],"prompt":"say \"hi\"\nuse `x` and ${y}"}]"#, + ); + // The double quotes inside the prompt must be backslash-escaped so the + // agent("...") literal is not terminated early. + assert!( + out.contains(r#"say \"hi\""#), + "inner quotes not escaped:\n{out}" + ); + assert!(out.contains("\\n"), "newline not escaped:\n{out}"); + // Backtick and ${ are safe inside a double-quoted literal and pass through. + assert!( + out.contains("`x`") && out.contains("${y}"), + "backtick/dollar mangled:\n{out}" + ); + } +} diff --git a/src/workflow_gen/step_map.rs b/src/workflow_gen/step_map.rs new file mode 100644 index 0000000..53a3055 --- /dev/null +++ b/src/workflow_gen/step_map.rs @@ -0,0 +1,48 @@ +//! Pure helpers for mapping a `StepSchema` onto a Claude workflow primitive. + +use crate::templates::schema::{ClassifierConfig, ClassifierOutputType}; + +/// Sanitize a step name into a valid JS identifier suffix (`r_`). +pub fn result_var(step_name: &str) -> String { + let cleaned: String = step_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + format!("r_{cleaned}") +} + +/// Synthesize a JSON-schema `serde_json::Value` for a classifier step that has +/// no explicit `json_schema`. The result is an object with a single `value` +/// property typed according to the classifier's output type. +pub fn classifier_schema(cfg: &ClassifierConfig) -> serde_json::Value { + let mut value_schema = serde_json::Map::new(); + value_schema.insert( + "type".to_string(), + serde_json::json!(classifier_value_type(&cfg.output_type)), + ); + if cfg.output_type == ClassifierOutputType::Enum { + if let Some(opts) = &cfg.options { + value_schema.insert("enum".to_string(), serde_json::json!(opts)); + } + } + if cfg.output_type == ClassifierOutputType::ShortString { + if let Some(max) = cfg.max_length { + value_schema.insert("maxLength".to_string(), serde_json::json!(max)); + } + } + serde_json::json!({ + "type": "object", + "properties": { "value": serde_json::Value::Object(value_schema) }, + "required": ["value"] + }) +} + +/// Map a `ClassifierOutputType` to its primitive JSON type name. +pub fn classifier_value_type(output: &ClassifierOutputType) -> &'static str { + match output { + ClassifierOutputType::Boolean => "boolean", + ClassifierOutputType::Number => "number", + ClassifierOutputType::ShortString | ClassifierOutputType::BigText => "string", + ClassifierOutputType::Enum => "string", + } +} diff --git a/tests/acp_integration.rs b/tests/acp_integration.rs new file mode 100644 index 0000000..45d52d2 --- /dev/null +++ b/tests/acp_integration.rs @@ -0,0 +1,334 @@ +//! End-to-end tests: spawn `operator acp` as a subprocess and roundtrip +//! real ACP messages over stdio. +//! +//! Phase A: `initialize` roundtrip. Phase B adds `session/new` and +//! `session/prompt` with `/bin/cat` as a stand-in delegator. + +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +const PER_LINE_TIMEOUT: Duration = Duration::from_secs(5); + +async fn read_line( + reader: &mut tokio::io::Lines>, +) -> String { + tokio::time::timeout(PER_LINE_TIMEOUT, reader.next_line()) + .await + .expect("timeout waiting for stdio response") + .expect("stdio read error") + .expect("eof before response") +} + +#[tokio::test] +async fn test_operator_acp_stdio_initialize_roundtrip() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-integration-test","version":"0.0.0"}}} +"#; + stdin.write_all(request).await.expect("write request"); + stdin.flush().await.expect("flush request"); + + let line = read_line(&mut reader).await; + let response: serde_json::Value = + serde_json::from_str(&line).expect("response should be valid JSON"); + assert_eq!(response["jsonrpc"], "2.0", "response missing jsonrpc=2.0"); + assert_eq!(response["id"], 1, "response id should echo request id"); + let result = response["result"] + .as_object() + .expect("result should be an object"); + assert_eq!( + result["protocolVersion"], 1, + "should echo protocolVersion 1" + ); + assert_eq!( + result["agentInfo"]["name"], "operator", + "agentInfo.name should identify operator: {result:?}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} + +fn write_sleep_delegator_config( + tickets_dir: &std::path::Path, +) -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::TempDir::new().unwrap(); + let config_path = temp.path().join("operator.toml"); + let body = format!( + r#" +[paths] +tickets = "{tickets}" +projects = "." +state = "{tickets}/operator" +worktrees = "/tmp/operator-worktrees" + +[llm_tools] + +[[llm_tools.detected]] +name = "sleeper" +path = "/bin/sleep" +version = "noop" +command_template = "sleep 60" + +[[delegators]] +name = "test-sleeper" +llm_tool = "sleeper" +model = "noop" + +[acp] +default_delegator = "test-sleeper" +"#, + tickets = tickets_dir.display() + ); + std::fs::write(&config_path, body).unwrap(); + (temp, config_path) +} + +/// Write a minimal TOML config that makes `/bin/cat` look like a configured +/// LLM tool + delegator, then return its path (kept alive by the returned +/// `TempDir`). +fn write_cat_delegator_config( + tickets_dir: &std::path::Path, +) -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::TempDir::new().unwrap(); + let config_path = temp.path().join("operator.toml"); + let body = format!( + r#" +[paths] +tickets = "{tickets}" +projects = "." +state = "{tickets}/operator" +worktrees = "/tmp/operator-worktrees" + +[llm_tools] + +[[llm_tools.detected]] +name = "cat" +path = "/bin/cat" +version = "noop" +command_template = "cat {{{{prompt_file}}}}" + +[[delegators]] +name = "test-cat" +llm_tool = "cat" +model = "noop" + +[acp] +default_delegator = "test-cat" +"#, + tickets = tickets_dir.display() + ); + std::fs::write(&config_path, body).unwrap(); + (temp, config_path) +} + +#[tokio::test] +async fn test_operator_acp_session_new_and_prompt_with_cat_delegator() { + let exe = env!("CARGO_BIN_EXE_operator"); + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let (_config_keep, config_path) = write_cat_delegator_config(tickets.path()); + + let mut child = Command::new(exe) + .arg("--config") + .arg(&config_path) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp with cat-delegator config"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + // 1. initialize + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-prompt-test","version":"0.0.0"}}}"#; + stdin.write_all(init).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let init_line = read_line(&mut reader).await; + let init_resp: serde_json::Value = serde_json::from_str(&init_line).unwrap(); + assert_eq!(init_resp["id"], 1, "initialize id mismatch: {init_resp}"); + + // 2. session/new with our cwd + let new_session = format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":"{}","mcpServers":[]}}}}"#, + canonical_cwd.display() + ); + stdin.write_all(new_session.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let new_line = read_line(&mut reader).await; + let new_resp: serde_json::Value = serde_json::from_str(&new_line).unwrap(); + assert_eq!(new_resp["id"], 2, "session/new id mismatch: {new_resp}"); + let session_id = new_resp["result"]["sessionId"] + .as_str() + .expect("sessionId in response: {new_resp}") + .to_string(); + assert!(!session_id.is_empty(), "sessionId must be non-empty"); + + // 3. session/prompt with text "hello" + let prompt = format!( + r#"{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{session_id}","prompt":[{{"type":"text","text":"hello"}}]}}}}"# + ); + stdin.write_all(prompt.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + // Read lines until we see (a) a session/update notification containing + // "hello" and (b) the session/prompt response with id=3. Streaming + // ordering isn't strict: assert both observed within ~10s budget. + let mut saw_hello_update = false; + let mut prompt_response: Option = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + let line = match tokio::time::timeout(Duration::from_secs(5), reader.next_line()).await { + Ok(Ok(Some(l))) => l, + Ok(Ok(None)) => break, + _ => break, + }; + let msg: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + if msg["method"] == "session/update" { + let text = msg["params"]["update"]["content"]["text"] + .as_str() + .unwrap_or(""); + if text.contains("hello") { + saw_hello_update = true; + } + } else if msg["id"] == 3 { + prompt_response = Some(msg); + break; + } + } + + let resp = prompt_response.expect("session/prompt response must arrive"); + assert!( + saw_hello_update, + "expected at least one session/update containing 'hello'; final response: {resp}" + ); + assert_eq!( + resp["result"]["stopReason"], "end_turn", + "cat exits 0, expected stopReason=end_turn: {resp}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} + +#[tokio::test] +async fn test_cancel_kills_delegator() { + let exe = env!("CARGO_BIN_EXE_operator"); + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let (_config_keep, config_path) = write_sleep_delegator_config(tickets.path()); + + let mut child = Command::new(exe) + .arg("--config") + .arg(&config_path) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp with sleep-delegator config"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + // 1. initialize + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-cancel-test","version":"0.0.0"}}}"#; + stdin.write_all(init).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let init_line = read_line(&mut reader).await; + let init_resp: serde_json::Value = serde_json::from_str(&init_line).unwrap(); + assert_eq!(init_resp["id"], 1); + + // 2. session/new + let new_session = format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":"{}","mcpServers":[]}}}}"#, + canonical_cwd.display() + ); + stdin.write_all(new_session.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let new_line = read_line(&mut reader).await; + let new_resp: serde_json::Value = serde_json::from_str(&new_line).unwrap(); + assert_eq!(new_resp["id"], 2); + let session_id = new_resp["result"]["sessionId"] + .as_str() + .expect("sessionId") + .to_string(); + + // 3. session/prompt (delegator runs `sleep 60` — a long-running process) + let prompt = format!( + r#"{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{session_id}","prompt":[{{"type":"text","text":"ignored"}}]}}}}"# + ); + stdin.write_all(prompt.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + // Give the delegator a moment to start + tokio::time::sleep(Duration::from_millis(500)).await; + + // 4. Send cancel notification (no id — it's a notification) + let cancel = format!( + r#"{{"jsonrpc":"2.0","method":"session/cancel","params":{{"sessionId":"{session_id}"}}}}"# + ); + stdin.write_all(cancel.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + // 5. The prompt response should arrive quickly with stopReason "cancelled" + let mut prompt_response: Option = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + let line = match tokio::time::timeout(Duration::from_secs(3), reader.next_line()).await { + Ok(Ok(Some(l))) => l, + Ok(Ok(None)) => break, + _ => break, + }; + let msg: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + if msg["id"] == 3 { + prompt_response = Some(msg); + break; + } + } + + let resp = prompt_response.expect("session/prompt response must arrive after cancel"); + assert_eq!( + resp["result"]["stopReason"], "cancelled", + "cancel should yield stopReason=cancelled: {resp}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index 57e5d6b..c1074b6 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -96,33 +96,28 @@ fn test_vscode_extension_has_all_core_operations() { } } -/// Test that REST API routes are registered for all Core Operations +/// Test that REST API routes are registered for all Core Operations. +/// +/// Asserts against the generated OpenAPI spec rather than grepping the router +/// source: since the router was migrated to `utoipa_axum::OpenApiRouter`, +/// mounting a route *is* registering it in the spec, so the spec is the +/// authoritative list of live routes. Path params use OpenAPI `{param}` syntax. #[test] fn test_api_routes_are_registered() { - // Read mod.rs to verify routes are registered - let mod_rs = include_str!("../src/rest/mod.rs"); - - // Check that pause/resume/sync endpoints are registered - assert!( - mod_rs.contains("/api/v1/queue/pause"), - "REST API should have /api/v1/queue/pause route" - ); - assert!( - mod_rs.contains("/api/v1/queue/resume"), - "REST API should have /api/v1/queue/resume route" - ); - assert!( - mod_rs.contains("/api/v1/queue/sync"), - "REST API should have /api/v1/queue/sync route" - ); - assert!( - mod_rs.contains("/api/v1/agents/:agent_id/approve"), - "REST API should have /api/v1/agents/:agent_id/approve route" - ); - assert!( - mod_rs.contains("/api/v1/agents/:agent_id/reject"), - "REST API should have /api/v1/agents/:agent_id/reject route" - ); + let spec = operator::rest::ApiDoc::json().expect("generate OpenAPI spec"); + + for route in [ + "/api/v1/queue/pause", + "/api/v1/queue/resume", + "/api/v1/queue/sync", + "/api/v1/agents/{agent_id}/approve", + "/api/v1/agents/{agent_id}/reject", + ] { + assert!( + spec.contains(route), + "REST API OpenAPI spec should document the {route} route" + ); + } } /// Test that all Core Operations are documented in session management docs diff --git a/tests/mcp_stdio_integration.rs b/tests/mcp_stdio_integration.rs new file mode 100644 index 0000000..bccd9cc --- /dev/null +++ b/tests/mcp_stdio_integration.rs @@ -0,0 +1,68 @@ +//! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip +//! a real JSON-RPC handshake over stdio. + +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn test_operator_mcp_stdio_initialize_and_list_tools() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator mcp"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + stdin + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n") + .await + .unwrap(); + stdin + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n") + .await + .unwrap(); + stdin.flush().await.unwrap(); + + let line1 = tokio::time::timeout(Duration::from_secs(5), reader.next_line()) + .await + .expect("timeout waiting for initialize response") + .expect("read err") + .expect("eof"); + let resp1: serde_json::Value = serde_json::from_str(&line1).unwrap(); + assert_eq!(resp1["id"], 1); + assert_eq!(resp1["result"]["serverInfo"]["name"], "operator"); + assert!( + resp1["result"]["capabilities"]["resources"].is_object(), + "resources capability should be advertised" + ); + + let line2 = tokio::time::timeout(Duration::from_secs(5), reader.next_line()) + .await + .expect("timeout waiting for tools/list response") + .expect("read err") + .expect("eof"); + let resp2: serde_json::Value = serde_json::from_str(&line2).unwrap(); + assert_eq!(resp2["id"], 2); + let tools = resp2["result"]["tools"].as_array().expect("tools array"); + assert!( + tools.len() >= 8, + "expected at least 8 read tools, got {}", + tools.len() + ); + let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert!( + names.contains(&"operator_list_tickets"), + "operator_list_tickets should be in tool list, got: {names:?}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} diff --git a/tests/surface_parity.rs b/tests/surface_parity.rs new file mode 100644 index 0000000..cd4b5d5 --- /dev/null +++ b/tests/surface_parity.rs @@ -0,0 +1,269 @@ +//! Surface Parity Integration Tests +//! +//! Validates that operator capabilities stay aligned across all integration +//! surfaces: slash commands (Zed), MCP tools, REST routes, and TUI keybindings. +//! +//! Uses `include_str!` to scan source files (same pattern as `feature_parity_test.rs`) +//! and the capability inventory from `src/integrations/inventory.rs`. + +use operator::integrations::all_capabilities; + +// ============================================================================ +// Source files scanned via include_str! +// ============================================================================ + +const EXTENSION_TOML: &str = include_str!("../zed-extension/extension.toml"); +const MCP_TOOLS_RS: &str = include_str!("../src/mcp/tools.rs"); +const KEYBINDINGS_RS: &str = include_str!("../src/ui/keybindings.rs"); + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Extract keybinding description strings from keybindings.rs source. +fn get_keybinding_descriptions() -> Vec { + let mut descriptions = Vec::new(); + for line in KEYBINDINGS_RS.lines() { + if let Some(start) = line.find("description:") { + if let Some(quote_start) = line[start..].find('"') { + let after_quote = &line[start + quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + descriptions.push(after_quote[..quote_end].to_string()); + } + } + } + } + descriptions +} + +/// Extract the path portion from a "METHOD /path" REST endpoint string. +fn rest_path(endpoint: &str) -> &str { + endpoint.split_whitespace().last().unwrap_or("") +} + +/// Convert axum-style `:param` path segments to OpenAPI `{param}` syntax so +/// inventory endpoints (written `:id`) match the generated spec (`{id}`). +fn colon_to_curly(path: &str) -> String { + path.split('/') + .map(|seg| { + seg.strip_prefix(':') + .map_or_else(|| seg.to_string(), |name| format!("{{{name}}}")) + }) + .collect::>() + .join("/") +} + +/// The generated OpenAPI spec — the authoritative list of mounted REST routes +/// since the router was migrated to `utoipa_axum::OpenApiRouter`. +fn rest_spec() -> String { + operator::rest::ApiDoc::json().expect("generate OpenAPI spec") +} + +// ============================================================================ +// Per-surface validation +// ============================================================================ + +/// Every capability with a `slash_command` must appear in extension.toml +#[test] +fn test_slash_commands_present_in_extension_toml() { + for cap in all_capabilities() { + if let Some(cmd) = cap.slash_command { + assert!( + EXTENSION_TOML.contains(cmd), + "Capability '{}': slash command '{}' not found in zed-extension/extension.toml", + cap.name, + cmd + ); + } + } +} + +/// Every capability with an `mcp_tool` must appear in tools.rs +#[test] +fn test_mcp_tools_present_in_tools_rs() { + for cap in all_capabilities() { + if let Some(tool) = cap.mcp_tool { + assert!( + MCP_TOOLS_RS.contains(tool), + "Capability '{}': MCP tool '{}' not found in src/mcp/tools.rs", + cap.name, + tool + ); + } + } +} + +/// Every capability with a `rest_endpoint` must appear in the generated OpenAPI +/// spec (the source of truth for mounted routes post-`OpenApiRouter` migration). +#[test] +fn test_rest_endpoints_present_in_rest_mod() { + let spec = rest_spec(); + for cap in all_capabilities() { + if let Some(endpoint) = cap.rest_endpoint { + let path = colon_to_curly(rest_path(endpoint)); + assert!( + spec.contains(&path), + "Capability '{}': REST path '{}' not found in the OpenAPI spec", + cap.name, + path + ); + } + } +} + +/// Every capability with a `tui_action` must match a keybinding description +#[test] +fn test_tui_actions_present_in_keybindings() { + let descriptions = get_keybinding_descriptions(); + for cap in all_capabilities() { + if let Some(action) = cap.tui_action { + let found = descriptions + .iter() + .any(|d| d.to_lowercase().contains(&action.to_lowercase())); + assert!( + found, + "Capability '{}': TUI action '{}' not found in keybinding descriptions.\nAvailable: {:?}", + cap.name, action, descriptions + ); + } + } +} + +// ============================================================================ +// Cross-surface coverage +// ============================================================================ + +/// Every slash command in extension.toml should map to at least one capability +#[test] +fn test_no_orphan_slash_commands() { + let caps = all_capabilities(); + let known_commands: Vec<&str> = caps.iter().filter_map(|c| c.slash_command).collect(); + + // Extract slash command keys from extension.toml + for line in EXTENSION_TOML.lines() { + let trimmed = line.trim(); + // Slash command lines look like: op-status = { description = "..." } + if trimmed.starts_with("op-") { + if let Some(key) = trimmed.split('=').next() { + let key = key.trim(); + assert!( + known_commands.contains(&key), + "Slash command '{key}' in extension.toml has no matching capability in inventory" + ); + } + } + } +} + +/// No MCP tool name referenced in inventory should be missing from tools.rs +/// (redundant with per-capability test, but guards against typos in the inventory) +#[test] +fn test_inventory_mcp_tools_are_real() { + for cap in all_capabilities() { + if let Some(tool) = cap.mcp_tool { + // Check that the tool name appears as a string literal in tools.rs + let quoted = format!("\"{tool}\""); + assert!( + MCP_TOOLS_RS.contains("ed), + "Capability '{}': MCP tool '{}' does not appear as a quoted string in tools.rs (possible typo)", + cap.name, + tool + ); + } + } +} + +// ============================================================================ +// Schema validation +// ============================================================================ + +/// Validate extension.toml parses as a valid Zed extension manifest. +/// Struct mirrors Zed's actual schema: metadata at top level, not nested +/// under `[extension]`. Catches missing required fields before Zed does. +#[test] +fn test_extension_toml_schema_valid() { + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct ExtensionToml { + id: String, + name: String, + version: String, + schema_version: u32, + #[serde(default)] + slash_commands: std::collections::HashMap, + #[serde(default)] + context_servers: std::collections::HashMap, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct SlashCommandEntry { + description: String, + requires_argument: bool, + } + + let parsed: ExtensionToml = + toml::from_str(EXTENSION_TOML).expect("extension.toml must parse as valid Zed manifest"); + + assert!(!parsed.id.is_empty()); + assert!(parsed.schema_version >= 1); + + for (name, entry) in &parsed.slash_commands { + assert!( + !entry.description.is_empty(), + "Slash command '{name}' has empty description" + ); + } +} + +// ============================================================================ +// Summary +// ============================================================================ + +/// Print a human-readable surface parity matrix +#[test] +fn test_surface_parity_summary() { + let descriptions = get_keybinding_descriptions(); + let spec = rest_spec(); + + println!("\n=== Surface Parity Matrix ===\n"); + println!( + "{:<22} | {:<7} | {:<7} | {:<7} | {:<7}", + "Capability", "Slash", "MCP", "REST", "TUI" + ); + println!( + "{:-<22}-+-{:-<7}-+-{:-<7}-+-{:-<7}-+-{:-<7}", + "", "", "", "", "" + ); + + for cap in all_capabilities() { + let has_slash = cap + .slash_command + .is_some_and(|cmd| EXTENSION_TOML.contains(cmd)); + let has_mcp = cap.mcp_tool.is_some_and(|tool| MCP_TOOLS_RS.contains(tool)); + let has_rest = cap + .rest_endpoint + .is_some_and(|ep| spec.contains(&colon_to_curly(rest_path(ep)))); + let has_tui = cap.tui_action.is_some_and(|action| { + descriptions + .iter() + .any(|d| d.to_lowercase().contains(&action.to_lowercase())) + }); + + let mark = |claimed: bool, present: bool| match (claimed, present) { + (true, true) => "Y", + (true, false) => "MISS", + (false, _) => "-", + }; + + println!( + "{:<22} | {:<7} | {:<7} | {:<7} | {:<7}", + cap.name, + mark(cap.slash_command.is_some(), has_slash), + mark(cap.mcp_tool.is_some(), has_mcp), + mark(cap.rest_endpoint.is_some(), has_rest), + mark(cap.tui_action.is_some(), has_tui), + ); + } + println!(); +} diff --git a/tests/ui_packaging.rs b/tests/ui_packaging.rs new file mode 100644 index 0000000..1a5eddd --- /dev/null +++ b/tests/ui_packaging.rs @@ -0,0 +1,119 @@ +//! Integration tests enforcing strict packaging constraints on the embedded UI. +//! +//! These tests run without the `embed-ui` feature — they validate the source +//! artifacts (package.json, dist directory) directly from the filesystem. + +use std::path::Path; + +const ALLOWED_RUNTIME_DEPS: &[&str] = &[ + "react", + "react-dom", + "react-router-dom", + "@dnd-kit/core", + "@dnd-kit/sortable", + "@dnd-kit/utilities", +]; + +const UNCOMPRESSED_BUDGET_BYTES: u64 = 10_485_760; // 10MB + +#[test] +fn test_ui_package_json_dep_allowlist() { + let pkg_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/package.json"); + assert!(pkg_path.exists(), "ui/package.json must exist"); + let content = std::fs::read_to_string(&pkg_path).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + + if let Some(deps) = pkg.get("dependencies").and_then(|d| d.as_object()) { + for dep_name in deps.keys() { + assert!( + ALLOWED_RUNTIME_DEPS.contains(&dep_name.as_str()), + "Unauthorized runtime dependency '{dep_name}' in ui/package.json. \ + Allowed: {ALLOWED_RUNTIME_DEPS:?}. Add to ALLOWED_RUNTIME_DEPS in tests/ui_packaging.rs if intentional.", + ); + } + } +} + +#[test] +fn test_ui_package_json_no_css_in_js() { + let pkg_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/package.json"); + if !pkg_path.exists() { + return; + } + let content = std::fs::read_to_string(&pkg_path).unwrap(); + + let banned = [ + "styled-components", + "@emotion/react", + "@emotion/styled", + "tailwindcss", + "@mui/material", + "chakra-ui", + ]; + for lib in &banned { + assert!( + !content.contains(lib), + "Banned CSS-in-JS / heavy UI library '{lib}' found in ui/package.json", + ); + } +} + +#[test] +fn test_ui_dist_size_budget_uncompressed() { + let dist_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/dist"); + if !dist_path.exists() || !dist_path.join("index.html").exists() { + return; // Not built yet — skip + } + + let total = walk_dir_size(&dist_path); + assert!( + total < UNCOMPRESSED_BUDGET_BYTES, + "ui/dist/ is {}B ({:.1}MB) uncompressed — exceeds 5MB budget", + total, + total as f64 / 1_048_576.0 + ); +} + +#[test] +fn test_ui_dist_no_source_maps_in_production() { + let dist_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/dist"); + if !dist_path.exists() { + return; + } + + let map_files = find_files_with_extension(&dist_path, "map"); + assert!( + map_files.is_empty(), + "Source maps found in ui/dist/ — these should not ship in the embedded binary: {map_files:?}", + ); +} + +fn walk_dir_size(dir: &Path) -> u64 { + let mut total = 0u64; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += walk_dir_size(&path); + } else if let Ok(meta) = path.metadata() { + total += meta.len(); + } + } + } + total +} + +fn find_files_with_extension(dir: &Path, ext: &str) -> Vec { + let mut results = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + results.extend(find_files_with_extension(&path, ext)); + } else if path.extension().is_some_and(|e| e == ext) { + results.push(path.display().to_string()); + } + } + } + results +} diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..c0881cc --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,266 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@operator/ui", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.6.1", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], + + "react-router-dom": ["react-router-dom@7.15.1", "", { "dependencies": { "react-router": "7.15.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..4ef4cf6 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Operator + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..27ae709 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "@operator/ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.6.1" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/ui/src/Layout.module.css b/ui/src/Layout.module.css new file mode 100644 index 0000000..f4368be --- /dev/null +++ b/ui/src/Layout.module.css @@ -0,0 +1,102 @@ +.layout { + display: flex; + min-height: 100vh; + font-family: var(--font-sans); + color: var(--text); + background: var(--color-bg); +} + +.nav { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--color-cream); + border-right: 1px solid var(--border); + padding: 24px 16px; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.brandRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} + +.brand { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-salmon); + letter-spacing: 0.02em; + text-decoration: none; +} + +.themeToggle { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-green-l1); + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 4px; + transition: color 0.2s; +} + +.themeToggle:hover { + color: var(--color-green-l2); +} + +.group { + margin-bottom: 20px; +} + +.groupLabel { + margin: 0 0 8px; + padding: 0 4px; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-cornflower); +} + +.navList { + list-style: none; + margin: 0; + padding: 0; +} + +.navList li { + margin-bottom: 6px; +} + +.navLink { + display: block; + padding: 8px 12px; + background: var(--color-green-l1); + color: var(--color-cream); + text-decoration: none; + font-size: 0.875rem; + border-radius: var(--radius); + transition: background-color 0.2s, color 0.2s; +} + +.navLink:hover { + background: var(--color-green-l2); + color: var(--color-cream); +} + +.active { + background: var(--color-green-l3); + color: var(--color-cream); +} + +.main { + flex: 1; + padding: 48px; + overflow-y: auto; +} diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx new file mode 100644 index 0000000..d3b1d11 --- /dev/null +++ b/ui/src/Layout.tsx @@ -0,0 +1,92 @@ +import { NavLink, Outlet, useLocation } from 'react-router-dom'; +import styles from './Layout.module.css'; +import { useTheme } from './theme'; + +interface NavItem { + to: string; + label: string; +} + +// "Status" mirrors the canonical section order shared with the TUI and VS Code +// extension (the SectionId enum in src/ui/status_panel.rs). Configuration and +// Issue Types keep their dedicated editor pages; the rest deep-link into the +// unified Status page. The target section is passed as a `?s=` query param +// (not a URL fragment) because a secondary hash breaks HashRouter routing. +// "Pages" are web-only views with no section analog. +const STATUS_ITEMS: NavItem[] = [ + { to: '/config', label: 'Configuration' }, + { to: '/status?s=connections', label: 'Connections' }, + { to: '/status?s=kanban', label: 'Kanban' }, + { to: '/status?s=llm', label: 'LLM Tools' }, + { to: '/status?s=model-servers', label: 'Model Servers' }, + { to: '/status?s=git', label: 'Git' }, + { to: '/issuetypes', label: 'Issue Types' }, + { to: '/status?s=delegators', label: 'Delegators' }, + { to: '/status?s=projects', label: 'Managed Projects' }, +]; + +const PAGE_ITEMS: NavItem[] = [ + { to: '/', label: 'Dashboard' }, + { to: '/queue', label: 'Queue' }, +]; + +function NavGroup({ label, items }: { label: string; items: NavItem[] }) { + const location = useLocation(); + + const isActive = (to: string): boolean => { + const [path, query] = to.split('?'); + if (location.pathname !== path) return false; + if (query) { + // e.g. "s=connections" must match the current ?s= param. + return `?${query}` === location.search; + } + // Plain route: active only when no section query is present. + return location.search === ''; + }; + + return ( +
+

{label}

+
    + {items.map((item) => ( +
  • + + {item.label} + +
  • + ))} +
+
+ ); +} + +export function Layout() { + const { theme, toggleTheme } = useTheme(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts new file mode 100644 index 0000000..5f5cb13 --- /dev/null +++ b/ui/src/api-client.ts @@ -0,0 +1,226 @@ +import type { Host } from './host'; +import type { HealthResponse } from '@operator/bindings/HealthResponse'; +import type { StatusResponse } from '@operator/bindings/StatusResponse'; +import type { SectionDto } from '@operator/bindings/SectionDto'; +import type { SectionRowDto } from '@operator/bindings/SectionRowDto'; +import type { QueueStatusResponse } from '@operator/bindings/QueueStatusResponse'; +import type { KanbanBoardResponse } from '@operator/bindings/KanbanBoardResponse'; +import type { KanbanTicketCard } from '@operator/bindings/KanbanTicketCard'; +import type { ActiveAgentsResponse } from '@operator/bindings/ActiveAgentsResponse'; +import type { IssueTypeSummary } from '@operator/bindings/IssueTypeSummary'; +import type { IssueTypeResponse } from '@operator/bindings/IssueTypeResponse'; +import type { CollectionResponse } from '@operator/bindings/CollectionResponse'; +import type { ProjectSummary } from '@operator/bindings/ProjectSummary'; +import type { CompletedTicket } from '@operator/bindings/CompletedTicket'; +import type { CreateIssueTypeRequest } from '@operator/bindings/CreateIssueTypeRequest'; +import type { UpdateIssueTypeRequest } from '@operator/bindings/UpdateIssueTypeRequest'; +import type { LaunchTicketRequest } from '@operator/bindings/LaunchTicketRequest'; +import type { LaunchTicketResponse } from '@operator/bindings/LaunchTicketResponse'; +import type { QueueControlResponse } from '@operator/bindings/QueueControlResponse'; +import type { Config } from '@operator/bindings/Config'; +import type { AgentDetailResponse } from '@operator/bindings/AgentDetailResponse'; +import type { WorkflowExportResponse } from '@operator/bindings/WorkflowExportResponse'; + +export type { + HealthResponse, + StatusResponse, + SectionDto, + SectionRowDto, + QueueStatusResponse, + KanbanBoardResponse, + KanbanTicketCard, + ActiveAgentsResponse, + IssueTypeSummary, + IssueTypeResponse, + CollectionResponse, + ProjectSummary, + CompletedTicket, + CreateIssueTypeRequest, + UpdateIssueTypeRequest, + LaunchTicketRequest, + LaunchTicketResponse, + QueueControlResponse, + Config, + AgentDetailResponse, + WorkflowExportResponse, +}; + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(base: string, path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, init); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); + throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); + } + return res.json() as Promise; +} + +async function requestVoid(base: string, path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, init); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); + throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); + } +} + +export class OperatorApi { + private base: string; + + constructor(host: Host) { + this.base = host.baseUrl(); + } + + // --- Health --- + + health(): Promise { + return request(this.base, '/api/v1/health'); + } + + status(): Promise { + return request(this.base, '/api/v1/status'); + } + + // --- Status sections (canonical, shared with TUI / VS Code) --- + + sections(): Promise { + return request(this.base, '/api/v1/sections'); + } + + // --- Queue --- + + queueStatus(): Promise { + return request(this.base, '/api/v1/queue/status'); + } + + kanban(): Promise { + return request(this.base, '/api/v1/queue/kanban'); + } + + pauseQueue(): Promise { + return request(this.base, '/api/v1/queue/pause', { method: 'POST' }); + } + + resumeQueue(): Promise { + return request(this.base, '/api/v1/queue/resume', { method: 'POST' }); + } + + syncKanban(): Promise { + return requestVoid(this.base, '/api/v1/queue/sync', { method: 'POST' }); + } + + // --- Agents --- + + activeAgents(): Promise { + return request(this.base, '/api/v1/agents/active'); + } + + getAgent(agentId: string): Promise { + return request(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}`); + } + + approveReview(agentId: string): Promise { + return requestVoid(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}/approve`, { + method: 'POST', + }); + } + + rejectReview(agentId: string, reason: string): Promise { + return requestVoid(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + }); + } + + // --- Tickets --- + + launchTicket(ticketId: string, options: LaunchTicketRequest): Promise { + return request(this.base, `/api/v1/tickets/${encodeURIComponent(ticketId)}/launch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + } + + // --- Projects --- + + listProjects(): Promise { + return request(this.base, '/api/v1/projects'); + } + + // --- Issue Types --- + + listIssueTypes(): Promise { + return request(this.base, '/api/v1/issuetypes'); + } + + getIssueType(key: string): Promise { + return request(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`); + } + + createIssueType(req: CreateIssueTypeRequest): Promise { + return request(this.base, '/api/v1/issuetypes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + } + + updateIssueType(key: string, req: UpdateIssueTypeRequest): Promise { + return request(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + } + + deleteIssueType(key: string): Promise { + return requestVoid(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`, { + method: 'DELETE', + }); + } + + // --- Collections --- + + listCollections(): Promise { + return request(this.base, '/api/v1/collections'); + } + + activateCollection(name: string): Promise { + return requestVoid(this.base, `/api/v1/collections/${encodeURIComponent(name)}/activate`, { + method: 'PUT', + }); + } + + // --- Configuration --- + + getConfiguration(): Promise { + return request(this.base, '/api/v1/configuration'); + } + + updateConfiguration(config: Partial): Promise { + return request(this.base, '/api/v1/configuration', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + } + + // --- Workflow export --- + + /** Export a ticket (rendered against its issue type) to a Claude dynamic workflow (.js). */ + exportWorkflow(ticketId: string): Promise { + return request( + this.base, + `/api/v1/tickets/${encodeURIComponent(ticketId)}/workflow-export`, + { method: 'POST' }, + ); + } +} diff --git a/ui/src/components/KanbanBoard.module.css b/ui/src/components/KanbanBoard.module.css new file mode 100644 index 0000000..9a424ab --- /dev/null +++ b/ui/src/components/KanbanBoard.module.css @@ -0,0 +1,97 @@ +.columns { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.column { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.columnHeader { + padding: 0.6rem 0.75rem; + font-weight: 600; + font-size: 0.8rem; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--border); +} + +.count { + color: var(--text-muted); + font-weight: 400; +} + +.cardList { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + min-height: 100px; +} + +.card { + background: var(--surface-alt); + border-radius: var(--radius); + padding: 0.6rem 0.75rem; + border-left: 3px solid var(--border); +} + +/* Priority colors mirror the TUI glyph coloring: P0 red, P1 yellow, P2 default, P3 muted. */ +.card[data-priority='p0'] { + border-left-color: var(--danger); +} +.card[data-priority='p1'] { + border-left-color: var(--warning); +} +.card[data-priority='p2'] { + border-left-color: var(--color-green-l1); +} +.card[data-priority='p3'] { + border-left-color: var(--border); +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.statusIcon { + font-size: 0.7rem; + color: var(--color-salmon); +} + +.ticketType { + font-size: 0.65rem; + font-weight: 600; + color: var(--color-salmon); + text-transform: uppercase; +} + +.ticketId { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: auto; +} + +.cardSummary { + font-size: 0.8rem; + margin-bottom: 0.25rem; + line-height: 1.3; +} + +.cardMeta { + font-size: 0.7rem; + color: var(--text-muted); +} + +.empty { + color: var(--text-muted); + font-size: 0.8rem; + text-align: center; + padding: 1.5rem 0; +} diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..20e456d --- /dev/null +++ b/ui/src/components/KanbanBoard.tsx @@ -0,0 +1,74 @@ +import type { KanbanBoardResponse } from '@operator/bindings/KanbanBoardResponse'; +import type { KanbanTicketCard } from '@operator/bindings/KanbanTicketCard'; +import styles from './KanbanBoard.module.css'; + +/** + * Three-column kanban board mirroring the operator TUI's ticket columns: + * TODO QUEUE / IN PROGRESS / DONE. The API's `awaiting` tickets are folded + * into IN PROGRESS (with a distinct paused indicator), matching the TUI which + * keeps awaiting tickets in the in-progress panel. + */ +export function KanbanBoard({ board }: { board: KanbanBoardResponse }) { + const inProgress = [...board.running, ...board.awaiting]; + return ( +
+ + + +
+ ); +} + +function Column({ title, tickets }: { title: string; tickets: KanbanTicketCard[] }) { + return ( +
+
+ {title} ({tickets.length}) +
+
+ {tickets.map((t) => ( + + ))} + {tickets.length === 0 &&
No tickets
} +
+
+ ); +} + +function Card({ ticket }: { ticket: KanbanTicketCard }) { + return ( +
+
+ {statusIcon(ticket.status)} + {ticket.ticket_type} + {ticket.id} +
+
{ticket.summary}
+
+ {ticket.project} · {ticket.step_display_name ?? ticket.step} +
+
+ ); +} + +function statusIcon(status: string): string { + switch (status) { + case 'running': + return '▶'; // ▶ + case 'awaiting': + case 'waiting': + case 'blocked': + return '⏸'; // ⏸ + case 'completed': + case 'done': + return '✓'; // ✓ + default: + return '•'; // • queued + } +} + +/** Maps "P0-critical".."P3-low" to a stable key for priority-colored styling. */ +function priorityKey(priority: string): string { + const match = priority.match(/^P([0-3])/i); + return match ? `p${match[1]}` : 'p2'; +} diff --git a/ui/src/host.ts b/ui/src/host.ts new file mode 100644 index 0000000..938d92b --- /dev/null +++ b/ui/src/host.ts @@ -0,0 +1,79 @@ +import { createContext, useContext } from 'react'; + +export interface Host { + baseUrl(): string; + openExternal(url: string): void; + browseFolder(): Promise; + openFile(path: string): void; +} + +class BrowserHost implements Host { + baseUrl(): string { + return window.location.origin; + } + + openExternal(url: string): void { + window.open(url, '_blank'); + } + + async browseFolder(): Promise { + return null; + } + + openFile(_path: string): void { + // no-op in browser context + } +} + +export type VscodeApi = { + postMessage(msg: unknown): void; +}; + +class VscodeHost implements Host { + private vscode: VscodeApi; + private apiUrl: string; + + constructor(vscode: VscodeApi, apiUrl: string) { + this.vscode = vscode; + this.apiUrl = apiUrl; + } + + baseUrl(): string { + return this.apiUrl; + } + + openExternal(url: string): void { + this.vscode.postMessage({ type: 'openExternal', url }); + } + + async browseFolder(): Promise { + return new Promise((resolve) => { + const handler = (event: MessageEvent) => { + if (event.data?.type === 'browseResult') { + window.removeEventListener('message', handler); + resolve(event.data.path ?? null); + } + }; + window.addEventListener('message', handler); + this.vscode.postMessage({ type: 'browseFolder', field: 'workingDirectory' }); + }); + } + + openFile(filePath: string): void { + this.vscode.postMessage({ type: 'openFile', filePath }); + } +} + +export function createBrowserHost(): Host { + return new BrowserHost(); +} + +export function createVscodeHost(vscode: VscodeApi, apiUrl: string): Host { + return new VscodeHost(vscode, apiUrl); +} + +export const HostContext = createContext(new BrowserHost()); + +export function useHost(): Host { + return useContext(HostContext); +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..38b2549 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,66 @@ +/* Operator UI theme + * The brand palette + green scale come from the shared single source of truth, + * docs/assets/css/tokens.css (imported below). This file adds only the + * app-specific semantic + layout tokens the docs site doesn't need + * (--surface, --border, --text, --danger, radii, fonts) on top of that palette. + */ + +@import "../../docs/assets/css/tokens.css"; + +:root { + /* UI semantic tokens (light) — built on the shared brand palette */ + --surface: var(--color-white); + --surface-alt: var(--color-cream); + --border: #e3dcc4; + --text: var(--color-teal); + --text-muted: var(--color-cornflower); + --danger: var(--color-salmon); + --danger-bg: #fbeae6; + --warning: #b8860b; + --warning-bg: #f7f0d8; + --success: var(--color-green-l2); + --success-bg: #e4f0ec; + + /* Layout tokens */ + --radius-sm: 3px; + --radius: 4px; + --radius-lg: 8px; + + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, + monospace; +} + +[data-theme="dark"] { + /* UI semantic tokens (dark) — brand vars themselves are overridden in tokens.css */ + --surface: #1a2426; + --surface-alt: #141b1c; + --border: #2c3a3c; + --text: var(--color-teal); + --text-muted: var(--color-cornflower); + --danger: var(--color-coral); + --danger-bg: #2a1a18; + --warning: #e0c060; + --warning-bg: #2a2818; + --success: var(--color-green-l3); + --success-bg: #16302a; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--color-coral); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..873be7b --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,33 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { HashRouter, Routes, Route } from 'react-router-dom'; +import './index.css'; +import { HostContext, createBrowserHost } from './host'; +import { Layout } from './Layout'; +import { DashboardPage } from './routes/DashboardPage'; +import { ConfigPage } from './routes/ConfigPage'; +import { IssueTypesPage } from './routes/IssueTypesPage'; +import { QueuePage } from './routes/QueuePage'; +import { StatusPage } from './routes/StatusPage'; +import { AgentDetailPage } from './routes/AgentDetailPage'; + +const host = createBrowserHost(); + +createRoot(document.getElementById('root')!).render( + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + , +); diff --git a/ui/src/routes/AgentDetailPage.module.css b/ui/src/routes/AgentDetailPage.module.css new file mode 100644 index 0000000..a7abe6d --- /dev/null +++ b/ui/src/routes/AgentDetailPage.module.css @@ -0,0 +1,135 @@ +.page { + padding: 24px; + max-width: 800px; +} + +.breadcrumb { + font-size: 0.875rem; + margin-bottom: 16px; + color: var(--text-muted); +} + +.breadcrumb a { + color: var(--color-salmon); + text-decoration: none; +} + +.title { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.statusBadge { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + font-weight: 600; +} + +.statusBadge[data-status="running"] { + background: var(--success-bg); + color: var(--success); +} + +.statusBadge[data-status="awaiting_input"] { + background: var(--warning-bg); + color: var(--warning); +} + +.statusBadge[data-status="completing"] { + background: var(--surface-alt); + color: var(--color-green-l2); +} + +.statusBadge[data-status="orphaned"] { + background: var(--danger-bg); + color: var(--danger); +} + +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.field { + background: var(--surface); + border: 1px solid var(--border); + padding: 12px; + border-radius: 6px; +} + +.fieldLabel { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.fieldValue { + font-size: 0.95rem; +} + +.prSection, .stepsSection, .meta { + margin-bottom: 16px; +} + +.prSection h2, .stepsSection h2 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 8px; +} + +.prSection a { + color: var(--color-salmon); +} + +.prStatus { + margin-left: 8px; + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 3px; + background: var(--surface); + border: 1px solid var(--border); +} + +.stepsList { + padding-left: 20px; +} + +.stepDone { + color: var(--success); +} + +.stepActive { + color: var(--warning); + font-weight: bold; +} + +.meta { + font-size: 0.875rem; + color: var(--text-muted); +} + +.meta code { + background: var(--surface); + border: 1px solid var(--border); + padding: 2px 6px; + border-radius: 3px; + font-size: 0.85rem; +} + +.error { + padding: 24px; + color: var(--danger); +} + +.loading { + padding: 24px; + color: var(--text-muted); +} diff --git a/ui/src/routes/AgentDetailPage.tsx b/ui/src/routes/AgentDetailPage.tsx new file mode 100644 index 0000000..7ae8d35 --- /dev/null +++ b/ui/src/routes/AgentDetailPage.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { OperatorApi } from '../api-client'; +import type { AgentDetailResponse } from '../api-client'; +import { useHost } from '../host'; +import styles from './AgentDetailPage.module.css'; + +export function AgentDetailPage() { + const { id } = useParams<{ id: string }>(); + const host = useHost(); + const [api] = useState(() => new OperatorApi(host)); + const [agent, setAgent] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + const load = () => { + api.getAgent(id).then(setAgent).catch((e) => setError(e.message)); + }; + load(); + const interval = setInterval(load, 5000); + return () => clearInterval(interval); + }, [api, id]); + + if (error) return
Error: {error}
; + if (!agent) return
Loading...
; + + const elapsed = formatElapsed(agent.started_at); + + return ( +
+
+ Dashboard / Agent {agent.id.slice(0, 8)} +
+ +

+ {agent.ticket_id} + + {agent.status} + +

+ +
+ + + + + + + + + + +
+ + {agent.pr_url && ( +
+

Pull Request

+ + {agent.pr_url} + + {agent.pr_status && ( + {agent.pr_status} + )} +
+ )} + + {agent.completed_steps.length > 0 && ( +
+

Completed Steps

+
    + {agent.completed_steps.map((step, i) => ( +
  1. {step}
  2. + ))} + {agent.current_step && ( +
  3. {agent.current_step}
  4. + )} +
+
+ )} + + {agent.worktree_path && ( +
+ Worktree: {agent.worktree_path} +
+ )} + +
+ Last Activity: {new Date(agent.last_activity).toLocaleString()} +
+
+ ); +} + +function Field({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value ?? '—'}
+
+ ); +} + +function formatElapsed(startedAt: string): string { + const ms = Date.now() - new Date(startedAt).getTime(); + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ${secs % 60}s`; + const hrs = Math.floor(mins / 60); + return `${hrs}h ${mins % 60}m`; +} diff --git a/ui/src/routes/ConfigPage.module.css b/ui/src/routes/ConfigPage.module.css new file mode 100644 index 0000000..5b929fb --- /dev/null +++ b/ui/src/routes/ConfigPage.module.css @@ -0,0 +1,140 @@ +.page { + max-width: 960px; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.loading { + color: var(--text-muted); + padding: 2rem; +} + +.error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.section { + margin-bottom: 2rem; +} + +.sectionTitle { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.kvGrid { + display: grid; + grid-template-columns: 160px 1fr; + gap: 0.4rem 1rem; + font-size: 0.875rem; +} + +.label { + color: var(--text-muted); +} + +.empty { + color: var(--text-muted); + font-size: 0.875rem; +} + +.collectionList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.collectionCard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.activeCollection { + border-color: var(--color-salmon); +} + +.collectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.collectionName { + font-weight: 600; +} + +.activeBadge { + font-size: 0.75rem; + color: var(--success); + background: var(--success-bg); + padding: 0.15rem 0.5rem; + border-radius: 4px; +} + +.activateBtn { + font-size: 0.75rem; + color: var(--color-salmon); + background: transparent; + border: 1px solid var(--color-salmon); + padding: 0.2rem 0.6rem; + border-radius: 4px; + cursor: pointer; +} + +.activateBtn:hover { + background: var(--surface-alt); +} + +.collectionDesc { + font-size: 0.825rem; + color: var(--text-muted); + margin: 0 0 0.5rem; +} + +.collectionTypes { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.typeTag { + font-size: 0.7rem; + background: var(--surface-alt); + color: var(--text-muted); + padding: 0.15rem 0.5rem; + border-radius: 3px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.825rem; +} + +.table th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-weight: 500; +} + +.table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} diff --git a/ui/src/routes/ConfigPage.tsx b/ui/src/routes/ConfigPage.tsx new file mode 100644 index 0000000..b3dd2bd --- /dev/null +++ b/ui/src/routes/ConfigPage.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { OperatorApi } from '../api-client'; +import type { StatusResponse, CollectionResponse, ProjectSummary } from '../api-client'; +import { useHost } from '../host'; +import styles from './ConfigPage.module.css'; + +export function ConfigPage() { + const host = useHost(); + const [api] = useState(() => new OperatorApi(host)); + const [status, setStatus] = useState(null); + const [collections, setCollections] = useState([]); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + Promise.all([ + api.status().then(setStatus), + api.listCollections().then(setCollections), + api.listProjects().then(setProjects), + ]) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [api]); + + const handleActivateCollection = async (name: string) => { + try { + await api.activateCollection(name); + const updated = await api.listCollections(); + setCollections(updated); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to activate collection'); + } + }; + + if (loading) return
Loading configuration...
; + + return ( +
+

Configuration

+ + {error &&
{error}
} + + {status && ( +
+

Status

+
+ Version + {status.version} + Issue Types + {status.issuetype_count} + Collections + {status.collection_count} + Active Collection + {status.active_collection} +
+
+ )} + +
+

Collections

+ {collections.length === 0 ? ( +

No collections configured.

+ ) : ( +
+ {collections.map((c) => ( +
+
+ {c.name} + {c.is_active ? ( + Active + ) : ( + + )} +
+

{c.description}

+
+ {c.types.map((t) => ( + {t} + ))} +
+
+ ))} +
+ )} +
+ +
+

Projects ({projects.length})

+ {projects.length === 0 ? ( +

No projects discovered.

+ ) : ( + + + + + + + + + + + {projects.map((p) => ( + + + + + + + ))} + +
NameKindLanguagesCatalog
{p.project_name}{p.kind ?? '—'}{p.languages.join(', ') || '—'}{p.has_catalog_info ? 'Yes' : 'No'}
+ )} +
+
+ ); +} diff --git a/ui/src/routes/DashboardPage.module.css b/ui/src/routes/DashboardPage.module.css new file mode 100644 index 0000000..0e8d85e --- /dev/null +++ b/ui/src/routes/DashboardPage.module.css @@ -0,0 +1,68 @@ +.page { + max-width: 1200px; +} + +.header { + display: flex; + align-items: baseline; + gap: 1rem; + margin-bottom: 1rem; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.75rem 1rem; + border-radius: var(--radius-lg); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.statusBanner { + background: var(--success-bg); + color: var(--success); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-lg); + font-size: 0.8rem; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1rem; + text-align: center; +} + +.cardValue { + font-size: 2rem; + font-weight: 700; + color: var(--color-salmon); +} + +.cardLabel { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.meta { + color: var(--text-muted); + font-size: 0.825rem; + margin-bottom: 1rem; +} diff --git a/ui/src/routes/DashboardPage.tsx b/ui/src/routes/DashboardPage.tsx new file mode 100644 index 0000000..97067c2 --- /dev/null +++ b/ui/src/routes/DashboardPage.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; +import { OperatorApi } from '../api-client'; +import type { HealthResponse, QueueStatusResponse, KanbanBoardResponse } from '../api-client'; +import { useHost } from '../host'; +import { KanbanBoard } from '../components/KanbanBoard'; +import styles from './DashboardPage.module.css'; + +const POLL_INTERVAL_MS = 3000; + +export function DashboardPage() { + const host = useHost(); + const [api] = useState(() => new OperatorApi(host)); + const [health, setHealth] = useState(null); + const [queue, setQueue] = useState(null); + const [board, setBoard] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const refresh = () => { + api + .kanban() + .then((b) => { + if (cancelled) return; + setBoard(b); + setError(null); + }) + .catch((e) => { + if (!cancelled) setError(e.message); + }); + api.queueStatus().then((q) => !cancelled && setQueue(q)).catch(() => {}); + api.health().then((h) => !cancelled && setHealth(h)).catch(() => {}); + }; + + refresh(); + const timer = setInterval(refresh, POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [api]); + + return ( +
+
+

Dashboard

+ {health && ( + + API: {health.status} · v{health.version} + + )} +
+ + {error &&
API: {error}
} + +
+ + + + +
+ + {board && ( + <> +
+ {board.total_count} tickets · updated{' '} + {new Date(board.last_updated).toLocaleTimeString()} +
+ + + )} +
+ ); +} + +function Card({ label, value }: { label: string; value?: number }) { + return ( +
+
{value ?? '—'}
+
{label}
+
+ ); +} diff --git a/ui/src/routes/IssueTypesPage.module.css b/ui/src/routes/IssueTypesPage.module.css new file mode 100644 index 0000000..5f54931 --- /dev/null +++ b/ui/src/routes/IssueTypesPage.module.css @@ -0,0 +1,150 @@ +.page { + max-width: 1100px; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.loading { + color: var(--text-muted); + padding: 2rem; +} + +.error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.split { + display: grid; + grid-template-columns: 280px 1fr; + gap: 1.5rem; +} + +.list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + text-align: left; + color: var(--text); + font: inherit; + transition: background 0.15s; +} + +.item:hover { + background: var(--surface-alt); +} + +.selectedItem { + background: var(--surface-alt); + border-color: var(--color-salmon); +} + +.glyph { + font-size: 1.25rem; + width: 2rem; + text-align: center; + flex-shrink: 0; +} + +.itemName { + font-size: 0.875rem; + font-weight: 500; +} + +.itemMeta { + font-size: 0.7rem; + color: var(--text-muted); +} + +.detail { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; +} + +.placeholder { + color: var(--text-muted); + font-size: 0.875rem; +} + +.detailTitle { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.detailGlyph { + font-size: 1.5rem; +} + +.detailDesc { + color: var(--text-muted); + font-size: 0.875rem; + margin: 0 0 1rem; +} + +.kvGrid { + display: grid; + grid-template-columns: 100px 1fr; + gap: 0.3rem 1rem; + font-size: 0.825rem; + margin-bottom: 1.25rem; +} + +.label { + color: var(--text-muted); +} + +.steps { + border-top: 1px solid var(--border); + padding-top: 1rem; +} + +.stepsTitle { + font-size: 0.9rem; + font-weight: 600; + margin: 0 0 0.75rem; +} + +.stepList { + margin: 0; + padding-left: 1.25rem; +} + +.step { + margin-bottom: 0.4rem; + font-size: 0.825rem; +} + +.stepName { + font-weight: 500; +} + +.stepMeta { + color: var(--text-muted); + margin-left: 0.5rem; + font-size: 0.75rem; +} diff --git a/ui/src/routes/IssueTypesPage.tsx b/ui/src/routes/IssueTypesPage.tsx new file mode 100644 index 0000000..f24e7b5 --- /dev/null +++ b/ui/src/routes/IssueTypesPage.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { OperatorApi } from '../api-client'; +import type { IssueTypeSummary, IssueTypeResponse } from '../api-client'; +import { useHost } from '../host'; +import styles from './IssueTypesPage.module.css'; + +export function IssueTypesPage() { + const host = useHost(); + const [api] = useState(() => new OperatorApi(host)); + const [issueTypes, setIssueTypes] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + api + .listIssueTypes() + .then(setIssueTypes) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [api]); + + const handleSelect = async (key: string) => { + try { + const detail = await api.getIssueType(key); + setSelected(detail); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load issue type'); + } + }; + + if (loading) return
Loading issue types...
; + + return ( +
+

Issue Types

+ + {error &&
{error}
} + +
+
+ {issueTypes.map((it) => ( + + ))} +
+ +
+ {selected ? ( + <> +

+ {selected.glyph} + {selected.name} +

+

{selected.description}

+
+ Key + {selected.key} + Mode + {selected.mode} + Source + {selected.source} + Steps + {selected.steps.length} +
+ {selected.steps.length > 0 && ( +
+

Workflow Steps

+
    + {selected.steps.map((step) => ( +
  1. + {step.display_name ?? step.name} + {step.review_type} review +
  2. + ))} +
+
+ )} + + ) : ( +
Select an issue type to view details.
+ )} +
+
+
+ ); +} diff --git a/ui/src/routes/QueuePage.module.css b/ui/src/routes/QueuePage.module.css new file mode 100644 index 0000000..b5b2402 --- /dev/null +++ b/ui/src/routes/QueuePage.module.css @@ -0,0 +1,105 @@ +.page { + max-width: 1200px; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.5rem; +} + +.loading { + color: var(--text-muted); + padding: 2rem; +} + +.error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.meta { + color: var(--text-muted); + font-size: 0.825rem; + margin-bottom: 1.25rem; +} + +.columns { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; +} + +.column { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.columnHeader { + padding: 0.6rem 0.75rem; + font-weight: 600; + font-size: 0.875rem; + border-bottom: 1px solid var(--border); +} + +.count { + color: var(--text-muted); + font-weight: 400; +} + +.cardList { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + min-height: 100px; +} + +.card { + background: var(--surface-alt); + border-radius: 6px; + padding: 0.6rem 0.75rem; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.ticketType { + font-size: 0.65rem; + font-weight: 600; + color: var(--color-salmon); + text-transform: uppercase; +} + +.ticketId { + font-size: 0.75rem; + color: var(--text-muted); +} + +.cardSummary { + font-size: 0.8rem; + margin-bottom: 0.25rem; + line-height: 1.3; +} + +.cardMeta { + font-size: 0.7rem; + color: var(--text-muted); +} + +.empty { + color: var(--text-muted); + font-size: 0.8rem; + text-align: center; + padding: 1.5rem 0; +} diff --git a/ui/src/routes/QueuePage.tsx b/ui/src/routes/QueuePage.tsx new file mode 100644 index 0000000..4884391 --- /dev/null +++ b/ui/src/routes/QueuePage.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { OperatorApi } from '../api-client'; +import type { KanbanBoardResponse } from '../api-client'; +import { useHost } from '../host'; +import { KanbanBoard } from '../components/KanbanBoard'; +import styles from './QueuePage.module.css'; + +const POLL_INTERVAL_MS = 3000; + +export function QueuePage() { + const host = useHost(); + const [api] = useState(() => new OperatorApi(host)); + const [board, setBoard] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const refresh = () => { + api + .kanban() + .then((b) => { + if (cancelled) return; + setBoard(b); + setError(null); + }) + .catch((e) => { + if (!cancelled) setError(e.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }; + + refresh(); + const timer = setInterval(refresh, POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [api]); + + if (loading) return
Loading queue...
; + + return ( +
+

Queue

+ + {error &&
{error}
} + + {board && ( + <> +
+ {board.total_count} tickets · updated{' '} + {new Date(board.last_updated).toLocaleTimeString()} +
+ + + )} +
+ ); +} diff --git a/ui/src/routes/StatusPage.module.css b/ui/src/routes/StatusPage.module.css new file mode 100644 index 0000000..247f923 --- /dev/null +++ b/ui/src/routes/StatusPage.module.css @@ -0,0 +1,127 @@ +.page { + max-width: 900px; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.75rem 1rem; + border-radius: var(--radius-lg); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.loading { + color: var(--text-muted); + padding: 1rem 0; + font-size: 0.875rem; +} + +.sections { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0.5rem 1rem; + scroll-margin-top: 1rem; +} + +.card[data-locked='true'] { + opacity: 0.7; +} + +.header { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0; + cursor: pointer; + list-style: none; +} + +.header::-webkit-details-marker { + display: none; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-muted); +} + +.dot[data-health='green'] { + background: var(--color-green-l1); +} +.dot[data-health='yellow'] { + background: var(--warning); +} +.dot[data-health='red'] { + background: var(--danger); +} +.dot[data-health='gray'] { + background: var(--text-muted); +} + +.label { + font-weight: 600; + font-size: 0.95rem; +} + +.description { + color: var(--text-muted); + font-size: 0.825rem; +} + +.lock { + margin-left: auto; + font-size: 0.8rem; +} + +.prereq { + margin: 0 0 0.5rem 1.6rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +.rows { + list-style: none; + margin: 0 0 0.5rem; + padding: 0 0 0 1.6rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.825rem; +} + +.rowLabel { + font-weight: 500; +} + +.rowDesc { + color: var(--text-muted); +} + +.empty { + margin: 0 0 0.5rem 1.6rem; + font-size: 0.8rem; + color: var(--text-muted); +} diff --git a/ui/src/routes/StatusPage.tsx b/ui/src/routes/StatusPage.tsx new file mode 100644 index 0000000..ee5be5c --- /dev/null +++ b/ui/src/routes/StatusPage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { OperatorApi } from '../api-client'; +import type { SectionDto } from '../api-client'; +import { useHost } from '../host'; +import styles from './StatusPage.module.css'; + +const POLL_INTERVAL_MS = 3000; + +export function StatusPage() { + const host = useHost(); + const [searchParams] = useSearchParams(); + const targetSection = searchParams.get('s'); + const [api] = useState(() => new OperatorApi(host)); + const [sections, setSections] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const refresh = () => { + api + .sections() + .then((s) => { + if (cancelled) return; + setSections(s); + setError(null); + }) + .catch((e) => { + if (!cancelled) setError(e.message); + }); + }; + refresh(); + const timer = setInterval(refresh, POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [api]); + + // Scroll to the targeted section (e.g. /status?s=git) once sections load. + useEffect(() => { + if (!sections || !targetSection) return; + const el = document.getElementById(targetSection); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [sections, targetSection]); + + return ( +
+

Status

+ + {error &&
API: {error}
} + {!sections && !error &&
Loading sections…
} + {sections && sections.length === 0 && !error && ( +
No sections available.
+ )} + +
+ {sections?.map((section) => ( + + ))} +
+
+ ); +} + +function SectionCard({ section }: { section: SectionDto }) { + return ( +
+
+ + + {section.label} + {section.description} + {!section.met && 🔒} + + + {!section.met && section.prerequisites.length > 0 && ( +

Requires: {section.prerequisites.join(', ')}

+ )} + + {section.children.length > 0 ? ( +
    + {section.children.map((row, i) => ( +
  • + + {row.label} + {row.description && {row.description}} +
  • + ))} +
+ ) : ( +

No details.

+ )} +
+
+ ); +} diff --git a/ui/src/theme.ts b/ui/src/theme.ts new file mode 100644 index 0000000..6752ea8 --- /dev/null +++ b/ui/src/theme.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'operator-theme'; + +function readStoredTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') { + return stored; + } + } catch { + // localStorage unavailable (e.g. restricted webview) — fall through + } + return 'light'; +} + +function applyTheme(theme: Theme): void { + document.documentElement.setAttribute('data-theme', theme); +} + +/** + * Theme state synced to localStorage and the document `data-theme` attribute. + * Defaults to the warm light theme (matching the docs site); a dark theme is + * available via the toggle. + */ +export function useTheme(): { theme: Theme; toggleTheme: () => void } { + const [theme, setTheme] = useState(readStoredTheme); + + useEffect(() => { + applyTheme(theme); + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch { + // ignore persistence failures + } + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + return { theme, toggleTheme }; +} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..cc9410c --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@operator/bindings/*": ["../bindings/*"] + } + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..43431e1 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,25 @@ +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + base: './', + resolve: { + alias: { + '@operator/bindings': path.resolve(__dirname, '../bindings'), + }, + }, + server: { + host: '127.0.0.1', + port: 5173, + proxy: { + '/api': 'http://127.0.0.1:7008', + '/swagger-ui': 'http://127.0.0.1:7008', + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}); diff --git a/vscode-extension/.eslintrc.json b/vscode-extension/.eslintrc.json deleted file mode 100644 index 3a26c50..0000000 --- a/vscode-extension/.eslintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "plugins": ["@typescript-eslint", "@stylistic/ts"], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended-type-checked", - "../.eslintrc.base.json" - ], - "env": { - "node": true, - "es2022": true - }, - "rules": { - "@typescript-eslint/naming-convention": [ - "warn", - { - "selector": "import", - "format": ["camelCase", "PascalCase"] - } - ], - "@stylistic/ts/semi": "warn", - "semi": "off" - }, - "ignorePatterns": ["out", "dist", "**/*.d.ts"] -} diff --git a/vscode-extension/.npmrc b/vscode-extension/.npmrc new file mode 100644 index 0000000..31479f9 --- /dev/null +++ b/vscode-extension/.npmrc @@ -0,0 +1,2 @@ +# min-release-age requires npm >=12 or a date-based 'before' workaround +# before=2026-05-20 diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 55818b5..ea6bcc8 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -31,6 +31,10 @@ VS Code terminal integration for [Operator](https://github.com/untra/operator) m 3. Click the "..." menu 4. Select "Install from VSIX..." +### Cursor + +This extension also runs in [Cursor](https://www.cursor.com). Install it from the [OpenVSX registry](https://open-vsx.org/) (Cursor's default extension search) or via the `.vsix` from GitHub releases. + ## Configuration | Setting | Default | Description | diff --git a/vscode-extension/eslint.config.mjs b/vscode-extension/eslint.config.mjs new file mode 100644 index 0000000..a190f72 --- /dev/null +++ b/vscode-extension/eslint.config.mjs @@ -0,0 +1,77 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import stylisticTs from '@stylistic/eslint-plugin-ts'; + +const baseRules = { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'curly': 'error', + 'eqeqeq': ['error', 'always', { null: 'ignore' }], + 'no-throw-literal': 'error', +}; + +export default tseslint.config( + { + ignores: ['out/**', 'dist/**', '**/*.d.ts', 'node_modules/**'], + }, + + { + files: ['src/**/*.ts', 'test/**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + plugins: { + '@stylistic/ts': stylisticTs, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + project: './tsconfig.json', + }, + }, + rules: { + ...baseRules, + '@typescript-eslint/naming-convention': ['warn', { + selector: 'import', + format: ['camelCase', 'PascalCase'], + }], + '@stylistic/ts/semi': 'warn', + 'semi': 'off', + }, + }, + + { + files: ['webview-ui/**/*.ts', 'webview-ui/**/*.tsx'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ], + plugins: { + '@stylistic/ts': stylisticTs, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + rules: { + ...baseRules, + '@typescript-eslint/consistent-type-assertions': ['error', { + assertionStyle: 'as', + objectLiteralTypeAssertions: 'never', + }], + '@typescript-eslint/naming-convention': ['warn', { + selector: 'import', + format: ['camelCase', 'PascalCase'], + }], + '@stylistic/ts/semi': 'warn', + 'semi': 'off', + }, + }, +); diff --git a/vscode-extension/images/icons/dist/operator-icons.css b/vscode-extension/images/icons/dist/operator-icons.css index 51890bf..5df7186 100644 --- a/vscode-extension/images/icons/dist/operator-icons.css +++ b/vscode-extension/images/icons/dist/operator-icons.css @@ -1,6 +1,6 @@ @font-face { font-family: "operator-icons"; - src: url("./operator-icons.woff?17728809643116deb872390fbf63aeea") format("woff"); + src: url("./operator-icons.woff?fcc8d5950361d91fb09f1e205e9c5b3b") format("woff"); } i[class^="opi-"]:before, i[class*=" opi-"]:before { @@ -17,39 +17,45 @@ i[class^="opi-"]:before, i[class*=" opi-"]:before { .opi-zellij:before { content: "\f101"; } -.opi-webhook:before { +.opi-zedindustries:before { content: "\f102"; } -.opi-vscode:before { +.opi-webhook:before { content: "\f103"; } -.opi-tmux:before { +.opi-vscode:before { content: "\f104"; } -.opi-notification:before { +.opi-tmux:before { content: "\f105"; } -.opi-linear:before { +.opi-notification:before { content: "\f106"; } -.opi-gitlab:before { +.opi-linear:before { content: "\f107"; } -.opi-github:before { +.opi-gitlab:before { content: "\f108"; } -.opi-gemini:before { +.opi-github:before { content: "\f109"; } -.opi-codex:before { +.opi-gemini:before { content: "\f10a"; } -.opi-cmux:before { +.opi-codex:before { content: "\f10b"; } -.opi-claude:before { +.opi-coder:before { content: "\f10c"; } -.opi-atlassian:before { +.opi-cmux:before { content: "\f10d"; } +.opi-claude:before { + content: "\f10e"; +} +.opi-atlassian:before { + content: "\f10f"; +} diff --git a/vscode-extension/images/icons/dist/operator-icons.json b/vscode-extension/images/icons/dist/operator-icons.json index a491878..42e41bf 100644 --- a/vscode-extension/images/icons/dist/operator-icons.json +++ b/vscode-extension/images/icons/dist/operator-icons.json @@ -1,15 +1,17 @@ { "zellij": 61697, - "webhook": 61698, - "vscode": 61699, - "tmux": 61700, - "notification": 61701, - "linear": 61702, - "gitlab": 61703, - "github": 61704, - "gemini": 61705, - "codex": 61706, - "cmux": 61707, - "claude": 61708, - "atlassian": 61709 + "zedindustries": 61698, + "webhook": 61699, + "vscode": 61700, + "tmux": 61701, + "notification": 61702, + "linear": 61703, + "gitlab": 61704, + "github": 61705, + "gemini": 61706, + "codex": 61707, + "coder": 61708, + "cmux": 61709, + "claude": 61710, + "atlassian": 61711 } \ No newline at end of file diff --git a/vscode-extension/images/icons/dist/operator-icons.woff b/vscode-extension/images/icons/dist/operator-icons.woff index d2e3ba4..6db006c 100644 Binary files a/vscode-extension/images/icons/dist/operator-icons.woff and b/vscode-extension/images/icons/dist/operator-icons.woff differ diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index 0af0236..ff58f47 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "operator-terminals", - "version": "0.1.28", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "operator-terminals", - "version": "0.1.28", + "version": "0.2.0", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -15,42 +15,62 @@ "@mui/material": "^5.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "smol-toml": "^1.3.0" + "smol-toml": "^1.6.1" }, "devDependencies": { + "@eslint/js": "^10.0.0", "@istanbuljs/nyc-config-typescript": "^1.0.2", - "@stylistic/eslint-plugin-ts": "^2.13.0", + "@stylistic/eslint-plugin-ts": "^4.4.1", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.6", - "@types/node": "20.x", + "@types/node": "25.x", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/sinon": "^17.0.3", - "@types/vscode": "^1.93.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vscode/test-cli": "^0.0.4", + "@types/vscode": "^1.120.0", + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.3.8", - "@vscode/vsce": "^2.22.0", - "c8": "^10.1.2", - "css-loader": "^7.1.0", - "eslint": "^8.56.0", + "@vscode/vsce": "^3.9.1", + "c8": "^11.0.0", + "css-loader": "^7.1.4", + "eslint": "^10.3.0", "fantasticon": "^4.1.0", - "glob": "^10.3.10", - "mocha": "^10.2.0", - "nyc": "^17.1.0", - "sinon": "^17.0.1", + "glob": "^13.0.6", + "mocha": "^11.7.5", + "nyc": "^18.0.0", + "ovsx": "^0.10.0", + "sinon": "^22.0.0", "source-map-support": "^0.5.21", "style-loader": "^4.0.0", - "ts-loader": "^9.5.0", - "typescript": "^5.3.3", - "webpack": "^5.97.0", - "webpack-cli": "^6.0.0" + "ts-loader": "^9.5.7", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", + "webpack": "^5.106.2", + "webpack-cli": "^7.0.2" }, "engines": { "vscode": "^1.93.0" } }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -99,9 +119,9 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", - "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -110,7 +130,7 @@ "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", + "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" }, "engines": { @@ -146,9 +166,9 @@ } }, "node_modules/@azure/identity": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", - "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", "dev": true, "license": "MIT", "dependencies": { @@ -159,8 +179,8 @@ "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^4.2.0", - "@azure/msal-node": "^3.5.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" }, @@ -183,22 +203,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.27.0.tgz", - "integrity": "sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz", + "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.13.3" + "@azure/msal-common": "16.6.2" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.13.3", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz", - "integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==", + "version": "16.6.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", + "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", "dev": true, "license": "MIT", "engines": { @@ -206,27 +226,26 @@ } }, "node_modules/@azure/msal-node": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.4.tgz", - "integrity": "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz", + "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.13.3", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" + "@azure/msal-common": "16.6.2", + "jsonwebtoken": "^9.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -235,9 +254,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -245,21 +264,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -293,13 +312,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -309,14 +328,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -353,37 +372,37 @@ "license": "ISC" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -393,27 +412,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -421,26 +440,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -450,40 +469,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -491,13 +510,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -514,15 +533,49 @@ } }, "node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", "dev": true, "license": "MIT", "engines": { "node": ">=14.17.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -542,27 +595,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@emotion/cache": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", @@ -709,6 +741,19 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -719,102 +764,128 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, "engines": { - "node": ">=10.10.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": "*" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -831,13 +902,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -857,33 +934,60 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@isaacs/fs-minipass": { @@ -926,16 +1030,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1033,9 +1127,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1324,65 +1418,358 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@node-rs/crc32": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.6.tgz", + "integrity": "sha512-+llXfqt+UzgoDzT9of5vPQPGqTAVCohU74I9zIBkNo5TH6s2P31DFJOGsJQKN207f0GHnYv5pV3wh3BCY/un/A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/crc32-android-arm-eabi": "1.10.6", + "@node-rs/crc32-android-arm64": "1.10.6", + "@node-rs/crc32-darwin-arm64": "1.10.6", + "@node-rs/crc32-darwin-x64": "1.10.6", + "@node-rs/crc32-freebsd-x64": "1.10.6", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.6", + "@node-rs/crc32-linux-arm64-gnu": "1.10.6", + "@node-rs/crc32-linux-arm64-musl": "1.10.6", + "@node-rs/crc32-linux-x64-gnu": "1.10.6", + "@node-rs/crc32-linux-x64-musl": "1.10.6", + "@node-rs/crc32-wasm32-wasi": "1.10.6", + "@node-rs/crc32-win32-arm64-msvc": "1.10.6", + "@node-rs/crc32-win32-ia32-msvc": "1.10.6", + "@node-rs/crc32-win32-x64-msvc": "1.10.6" + } + }, + "node_modules/@node-rs/crc32-android-arm-eabi": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.6.tgz", + "integrity": "sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@node-rs/crc32-android-arm64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.6.tgz", + "integrity": "sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 8" + "node": ">= 10" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "node_modules/@node-rs/crc32-darwin-arm64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz", + "integrity": "sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">= 10" } }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/@node-rs/crc32-darwin-x64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.6.tgz", + "integrity": "sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-freebsd-x64": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.6.tgz", + "integrity": "sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.6.tgz", + "integrity": "sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-gnu": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.6.tgz", + "integrity": "sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-musl": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.6.tgz", + "integrity": "sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-gnu": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.6.tgz", + "integrity": "sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-musl": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.6.tgz", + "integrity": "sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-wasm32-wasi": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.6.tgz", + "integrity": "sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/crc32-win32-arm64-msvc": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.6.tgz", + "integrity": "sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-ia32-msvc": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.6.tgz", + "integrity": "sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-x64-msvc": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.6.tgz", + "integrity": "sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, @@ -1420,296 +1807,358 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "type-detect": "4.0.8" + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", - "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.1", - "type-detect": "^4.1.0" + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@sinonjs/samsam/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", "dev": true, "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, "engines": { - "node": ">=4" + "node": ">=20.0.0" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", - "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, - "license": "(Unlicense OR Apache-2.0)" + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } }, - "node_modules/@stylistic/eslint-plugin-ts": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin-ts/-/eslint-plugin-ts-2.13.0.tgz", - "integrity": "sha512-nooe1oTwz60T4wQhZ+5u0/GAu3ygkKF9vPPZeRn/meG71ntQ0EZXVOKEonluAYl/+CV2T+nN0dknHa4evAW13Q==", + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.13.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0" + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": ">=8.40.0" + "node": ">=20.0.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", - "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1" + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=20.0.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/@typescript-eslint/types": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", - "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=20.0.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", - "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.1", - "@typescript-eslint/tsconfig-utils": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node": ">=20.0.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/@typescript-eslint/utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", - "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", - "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@stylistic/eslint-plugin-ts": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin-ts/-/eslint-plugin-ts-4.4.1.tgz", + "integrity": "sha512-2r6cLcmdF6til66lx8esBYvBvsn7xCmLT50gw/n1rGGlTq/OxeNjBIh4c3VEaDGMa/5TybrZTia6sQUHdIWx1w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "eslint-visitor-keys": "^5.0.0" + "@typescript-eslint/utils": "^8.32.1", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "eslint": ">=9.0.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT" }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT" }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8" } }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT" }, - "node_modules/@stylistic/eslint-plugin-ts/node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } + "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" + "@textlint/ast-node-types": "15.7.1" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" + "tslib": "^2.4.0" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1753,15 +2202,22 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -1775,9 +2231,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1803,6 +2259,13 @@ "@types/react": "*" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sax": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", @@ -1813,13 +2276,6 @@ "@types/node": "*" } }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/sinon": { "version": "17.0.4", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", @@ -1838,86 +2294,75 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.108.1", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.108.1.tgz", - "integrity": "sha512-DerV0BbSzt87TbrqmZ7lRDIYaMiqvP8tmJTzW2p49ZBVtGUnGAu2RGQd1Wv4XMzEVUpaHbsemVM5nfuQJj7H6w==", + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", - "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.1", - "@typescript-eslint/types": "^8.57.1", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -1928,15 +2373,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", - "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1945,30 +2394,79 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", - "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1977,163 +2475,268 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/test-cli": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.12.tgz", + "integrity": "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mocha": "^10.0.10", + "c8": "^10.1.3", + "chokidar": "^3.6.0", + "enhanced-resolve": "^5.18.3", + "glob": "^10.3.10", + "minimatch": "^9.0.3", + "mocha": "^11.7.4", + "supports-color": "^10.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vscode/test-cli/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/test-cli/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vscode/test-cli/node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/@vscode/test-cli/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "bin": { + "glob": "dist/esm/bin.mjs" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "node_modules/@vscode/test-cli/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT", + "license": "ISC" + }, + "node_modules/@vscode/test-cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": ">=16 || 14 >=14.17" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "node_modules/@vscode/test-cli/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "BSD-2-Clause", + "license": "BlueOak-1.0.0", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": ">=16 || 14 >=14.18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "node_modules/@vscode/test-cli/node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "node": ">=18" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "node_modules/@vscode/test-cli/node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" - }, "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "18 || 20 || >=22" } }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "node_modules/@vscode/test-cli/node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=20.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vscode/test-cli": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.4.tgz", - "integrity": "sha512-Tx0tfbxeSb2Xlo+jpd+GJrNLgKQHobhRHrYvOipZRZQYWZ82sKiK02VY09UjU1Czc/YnZnqyAnjUfaVGl3h09w==", + "node_modules/@vscode/test-cli/node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@types/mocha": "^10.0.2", - "chokidar": "^3.5.3", - "glob": "^10.3.10", - "minimatch": "^9.0.3", - "mocha": "^10.2.0", - "supports-color": "^9.4.0", - "yargs": "^17.7.2" + "brace-expansion": "^5.0.5" }, - "bin": { - "vscode-test": "out/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@vscode/test-electron": { @@ -2154,42 +2757,47 @@ } }, "node_modules/@vscode/vsce": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", - "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", + "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", "dev": true, "license": "MIT", "dependencies": { "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", - "chalk": "^2.4.2", + "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", - "commander": "^6.2.1", + "commander": "^12.1.0", "form-data": "^4.0.0", - "glob": "^7.0.6", + "glob": "^11.0.0", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", - "markdown-it": "^12.3.2", + "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", + "secretlint": "^10.1.2", "semver": "^7.5.2", - "tmp": "^0.2.1", + "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^2.3.1", + "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "bin": { "vsce": "vsce" }, "engines": { - "node": ">= 16" + "node": ">= 20" }, "optionalDependencies": { "keytar": "^7.7.0" @@ -2340,43 +2948,84 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@vscode/vsce/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@vscode/vsce/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@vscode/vsce/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2386,6 +3035,17 @@ "node": "*" } }, + "node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -2547,62 +3207,14 @@ "@xtuc/long": "4.2.2" } }, - "node_modules/@webpack-cli/configtest": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", - "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - } - }, - "node_modules/@webpack-cli/info": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", - "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - } - }, - "node_modules/@webpack-cli/serve": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", - "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, "node_modules/@xmldom/xmldom": { - "version": "0.7.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", - "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", - "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", "dev": true, "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=14.6" } }, "node_modules/@xtuc/ieee754": { @@ -2630,9 +3242,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2690,16 +3302,16 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2724,61 +3336,62 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/anymatch": { @@ -2822,10 +3435,10 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", "engines": { @@ -2866,11 +3479,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -2895,13 +3511,16 @@ "optional": true }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -2917,6 +3536,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2963,14 +3598,24 @@ "dev": true, "license": "ISC" }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -2994,9 +3639,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3014,11 +3659,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3123,9 +3768,9 @@ } }, "node_modules/c8": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", - "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", "dev": true, "license": "ISC", "dependencies": { @@ -3136,7 +3781,7 @@ "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "test-exclude": "^7.0.1", + "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -3145,7 +3790,7 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=18" + "node": "20 || >=22" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -3156,16 +3801,6 @@ } } }, - "node_modules/c8/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -3190,6 +3825,45 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/cacache/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -3197,17 +3871,37 @@ "dev": true, "license": "ISC" }, - "node_modules/cacache/node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/caching-transform": { @@ -3293,22 +3987,19 @@ } }, "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001763", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", - "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -3337,37 +4028,39 @@ } }, "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chalk/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/cheerio": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", - "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", "dev": true, "license": "MIT", "dependencies": { @@ -3376,11 +4069,11 @@ "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.0.0", + "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^7.12.0", + "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" }, "engines": { @@ -3451,6 +4144,13 @@ "node": ">=6.0" } }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -3522,82 +4222,29 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" } }, - "node_modules/cliui/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -3633,26 +4280,22 @@ } }, "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -3670,13 +4313,13 @@ } }, "node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/commondir": { @@ -3738,9 +4381,9 @@ } }, "node_modules/css-loader": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.3.tgz", - "integrity": "sha512-frbERmjT0UC5lMheWpJmMilnt9GEhbZJN/heUb7/zaJYeIzj5St9HvDcfshzzOqbsS+rYpMk++2SD3vGETDSyA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "dev": true, "license": "MIT", "dependencies": { @@ -3761,7 +4404,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "peerDependenciesMeta": { @@ -3861,16 +4504,13 @@ } }, "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, "node_modules/decompress-response": { @@ -3909,9 +4549,9 @@ "license": "MIT" }, "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -3954,6 +4594,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -3961,10 +4619,28 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/delayed-stream": { @@ -3989,41 +4665,15 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -4125,17 +4775,34 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, @@ -4176,14 +4843,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -4225,6 +4892,19 @@ "node": ">=4" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -4255,23 +4935,22 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -4371,177 +5050,151 @@ } }, "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", "engines": { - "node": ">=0.8.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "color-name": "~1.1.4" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/glob-parent": { @@ -4557,41 +5210,22 @@ "node": ">=10.13.0" } }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": ">= 4" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/esniff": { "version": "2.0.1", @@ -4610,18 +5244,18 @@ } }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4756,104 +5390,20 @@ "ttf2woff2": "^8.0.0" }, "bin": { - "fantasticon": "bin/fantasticon" - }, - "engines": { - "node": ">= 22.0" - } - }, - "node_modules/fantasticon/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/fantasticon/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/fantasticon/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/fantasticon/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fantasticon/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/fantasticon/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" + "fantasticon": "bin/fantasticon" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 22.0" } }, - "node_modules/fantasticon/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "node_modules/fantasticon/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=20" } }, "node_modules/fast-deep-equal": { @@ -4895,9 +5445,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -4931,27 +5481,17 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/file-uri-to-path": { @@ -5052,27 +5592,47 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5136,6 +5696,21 @@ "license": "MIT", "optional": true }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -5149,13 +5724,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5201,9 +5769,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -5271,21 +5839,18 @@ "optional": true }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5311,54 +5876,52 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.20.2" + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/globby/node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", "dev": true, "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5384,17 +5947,10 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5413,14 +5969,37 @@ "uglify-js": "^3.1.4" } }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-symbols": { @@ -5469,20 +6048,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hasha/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5537,9 +6106,9 @@ "license": "MIT" }, "node_modules/htmlparser2": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -5552,14 +6121,14 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, "node_modules/htmlparser2/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5653,9 +6222,9 @@ "optional": true }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -5725,16 +6294,17 @@ "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/inherits": { @@ -5763,9 +6333,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -5791,13 +6361,26 @@ "node": ">=8" } }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -5887,6 +6470,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-it-type": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", + "integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globalthis": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5981,9 +6577,9 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -6061,9 +6657,9 @@ } }, "node_modules/istanbul-lib-processinfo": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", - "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", + "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6071,9 +6667,22 @@ "cross-spawn": "^7.0.3", "istanbul-lib-coverage": "^3.2.0", "p-map": "^3.0.0", - "rimraf": "^3.0.0", + "rimraf": "^6.1.3", "uuid": "^8.3.2" }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, "engines": { "node": ">=8" } @@ -6093,16 +6702,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-lib-report/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6131,6 +6730,16 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -6145,6 +6754,24 @@ "node": ">=8" } }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -6176,16 +6803,6 @@ "node": ">= 10.13.0" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -6247,9 +6864,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -6280,6 +6897,19 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -6313,15 +6943,8 @@ "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/just-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", - "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", - "dev": true, - "license": "MIT" + "setimmediate": "^1.0.5" + } }, "node_modules/jwa": { "version": "2.0.1", @@ -6420,19 +7043,29 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true, "license": "MIT", "engines": { @@ -6460,9 +7093,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -6515,13 +7148,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -6529,6 +7155,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6546,82 +7179,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/log-symbols/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6697,30 +7254,31 @@ } }, "node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "bin": { - "markdown-it": "bin/markdown-it.js" - } - }, - "node_modules/markdown-it/node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", - "dev": true, - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/math-intrinsics": { @@ -6734,9 +7292,9 @@ } }, "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true, "license": "MIT" }, @@ -6862,16 +7420,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6929,11 +7487,11 @@ } }, "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "minipass": "^3.0.0" }, @@ -7028,31 +7586,32 @@ "optional": true }, "node_modules/mocha": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", - "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^5.2.0", + "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", + "minimatch": "^9.0.5", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { @@ -7060,187 +7619,132 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/mocha/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/mocha/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "license": "MIT" }, - "node_modules/mocha/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "balanced-match": "^1.0.0" } }, - "node_modules/mocha/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "readdirp": "^4.0.1" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/mocha/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "node": ">= 14.16.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://paulmillr.com/funding/" } }, "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=12" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/mocha/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "ISC" }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=10" - } - }, - "node_modules/mocha/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "node": ">=16 || 14 >=14.17" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/mocha/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "has-flag": "^4.0.0" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, "engines": { - "node": ">=10" + "node": ">= 14.18.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/mocha/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/ms": { @@ -7257,16 +7761,16 @@ "license": "ISC" }, "node_modules/nan": { - "version": "2.26.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", - "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -7321,24 +7825,10 @@ "dev": true, "license": "ISC" }, - "node_modules/nise": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", - "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0", - "@sinonjs/fake-timers": "^11.2.2", - "@sinonjs/text-encoding": "^0.7.2", - "just-extend": "^6.2.0", - "path-to-regexp": "^6.2.1" - } - }, "node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "dev": true, "license": "MIT", "optional": true, @@ -7422,11 +7912,28 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } }, "node_modules/nopt": { "version": "8.1.0", @@ -7444,6 +7951,41 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7468,9 +8010,9 @@ } }, "node_modules/nyc": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", - "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-18.0.0.tgz", + "integrity": "sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==", "dev": true, "license": "ISC", "dependencies": { @@ -7483,11 +8025,11 @@ "find-up": "^4.1.0", "foreground-child": "^3.3.0", "get-package-type": "^0.1.0", - "glob": "^7.1.6", + "glob": "^13.0.6", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-hook": "^3.0.0", "istanbul-lib-instrument": "^6.0.2", - "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-processinfo": "^3.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.0.2", @@ -7496,54 +8038,27 @@ "p-map": "^3.0.0", "process-on-spawn": "^1.0.0", "resolve-from": "^5.0.0", - "rimraf": "^3.0.0", + "rimraf": "^6.1.3", "signal-exit": "^3.0.2", - "spawn-wrap": "^2.0.0", - "test-exclude": "^6.0.0", + "spawn-wrap": "^3.0.0", + "test-exclude": "^8.0.0", "yargs": "^15.0.2" }, "bin": { "nyc": "bin/nyc.js" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, - "node_modules/nyc/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/nyc/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/nyc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/nyc/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" } }, "node_modules/nyc/node_modules/cliui": { @@ -7558,43 +8073,6 @@ "wrap-ansi": "^6.2.0" } }, - "node_modules/nyc/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/nyc/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nyc/node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nyc/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/nyc/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -7609,28 +8087,6 @@ "node": ">=8" } }, - "node_modules/nyc/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/nyc/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -7660,19 +8116,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nyc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/nyc/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -7702,6 +8145,19 @@ "node": ">=8" } }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nyc/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -7729,31 +8185,14 @@ "dev": true, "license": "ISC" }, - "node_modules/nyc/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/nyc/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" @@ -7840,12 +8279,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "wrappy": "1" } @@ -7927,19 +8377,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ora/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -8021,20 +8458,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/ovsx": { + "version": "0.10.12", + "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-0.10.12.tgz", + "integrity": "sha512-WwMj1iQDvCk02029oxPnkFXsPrHZ+WzmoNW5pJ8JGepHtL30i2JE4s3C3wqzQqj6a35vx2hp0gV3TdfefGmvMg==", "dev": true, - "license": "MIT", + "license": "EPL-2.0", "dependencies": { - "ansi-regex": "^6.0.1" + "@vscode/vsce": "^3.7.1", + "commander": "^6.2.1", + "follow-redirects": "^1.16.0", + "is-ci": "^2.0.0", + "leven": "^3.1.0", + "semver": "^7.6.0", + "tmp": "^0.2.3", + "yauzl-promise": "^4.0.0" }, - "engines": { - "node": ">=12" + "bin": { + "ovsx": "bin/ovsx" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "engines": { + "node": ">= 20" + } + }, + "node_modules/ovsx/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" } }, "node_modules/p-limit": { @@ -8070,16 +8524,16 @@ } }, "node_modules/p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-try": { @@ -8235,16 +8689,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8262,35 +8706,31 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -8315,9 +8755,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -8393,13 +8833,23 @@ "p-limit": "^2.2.0" }, "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -8417,7 +8867,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8513,6 +8963,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "dev": true, "license": "MIT", "optional": true, @@ -8609,9 +9060,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "optional": true, @@ -8630,10 +9081,20 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8694,6 +9155,19 @@ "rc": "cli.js" } }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -8731,9 +9205,9 @@ } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT" }, "node_modules/react-transition-group": { @@ -8765,6 +9239,70 @@ "node": ">=0.8" } }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -8855,11 +9393,12 @@ "license": "ISC" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -8945,68 +9484,25 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^7.1.3" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "rimraf": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -9073,9 +9569,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -9111,47 +9607,32 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/schema-utils/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" + "bin": { + "secretlint": "bin/secretlint.js" }, - "peerDependencies": { - "ajv": "^8.8.2" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -9242,14 +9723,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9359,62 +9840,78 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-invariant": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz", + "integrity": "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/sinon": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", - "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0", - "@sinonjs/fake-timers": "^11.2.2", - "@sinonjs/samsam": "^8.0.0", - "diff": "^5.1.0", - "nise": "^5.1.5", - "supports-color": "^7.2.0" + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/sinon/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">=8" + "node": ">=0.3.1" } }, - "node_modules/sinon/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/slugify": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", - "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "dev": true, "license": "MIT", "engines": { @@ -9433,9 +9930,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -9445,13 +9942,13 @@ } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -9475,10 +9972,9 @@ } }, "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9505,17 +10001,28 @@ "source-map": "^0.6.0" } }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spawn-wrap": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-3.0.0.tgz", + "integrity": "sha512-z+s5vv4KzFPJVddGab0xX2n7kQPGMdNUX5l9T8EJqsXdKTWpcxmAqWHpsgHEXoC1taGBCc7b79bi62M5kdbrxQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { + "cross-spawn": "^7.0.6", "foreground-child": "^2.0.0", "is-windows": "^1.0.2", "make-dir": "^3.0.0", - "rimraf": "^3.0.0", + "rimraf": "^6.1.3", "signal-exit": "^3.0.2", "which": "^2.0.1" }, @@ -9570,6 +10077,42 @@ "dev": true, "license": "ISC" }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9621,21 +10164,18 @@ "license": "MIT" }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -9654,34 +10194,60 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } }, "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -9690,7 +10256,8 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi": { + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -9703,16 +10270,12 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { "node": ">=8" } @@ -9740,6 +10303,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, "node_modules/style-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", @@ -9764,18 +10337,48 @@ "license": "MIT" }, "node_modules/supports-color": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", - "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -9799,13 +10402,13 @@ } }, "node_modules/svg2ttf": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svg2ttf/-/svg2ttf-6.0.3.tgz", - "integrity": "sha512-CgqMyZrbOPpc+WqH7aga4JWkDPso23EgypLsbQ6gN3uoPWwwiLjXvzgrwGADBExvCRJrWFzAeK1bSoSpE7ixSQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/svg2ttf/-/svg2ttf-6.1.0.tgz", + "integrity": "sha512-EjxgcmhKcBpx/3fR1hPwVtJAbUc/ZsDpwOTF74SI3PbzCg4pDHnxVmoSuqgEqxVJGqqkSCI6+82cucpn2D5aOw==", "dev": true, "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.7.2", + "@xmldom/xmldom": "^0.9.10", "argparse": "^2.0.1", "cubic2quad": "^1.2.1", "lodash": "^4.17.10", @@ -9849,39 +10452,6 @@ "node": ">=18" } }, - "node_modules/svgicons2svgfont/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/svgicons2svgfont/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/svgicons2svgfont/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/svgicons2svgfont/node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -9923,63 +10493,60 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/svgicons2svgfont/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "node_modules/svgpath": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", + "integrity": "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" + "license": "MIT", + "funding": { + "url": "https://github.com/fontello/svg2ttf?sponsor=1" } }, - "node_modules/svgicons2svgfont/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "BSD-3-Clause", "dependencies": { - "brace-expansion": "^5.0.2" + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=10.0.0" } }, - "node_modules/svgicons2svgfont/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=8" } }, - "node_modules/svgpath": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", - "integrity": "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==", + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/fontello/svg2ttf?sponsor=1" + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -9991,9 +10558,9 @@ } }, "node_modules/tar": { - "version": "7.5.12", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz", - "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==", + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -10075,10 +10642,27 @@ "node": ">=18" } }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -10095,16 +10679,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -10118,12 +10701,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -10137,43 +10747,43 @@ "license": "MIT" }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" + "glob": "^13.0.6", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", "dependencies": { - "brace-expansion": "^2.0.1" + "editions": "^6.21.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://bevry.me/fund" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/timers-ext": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", @@ -10189,14 +10799,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10224,9 +10834,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10270,100 +10880,37 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-loader": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", - "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, "engines": { - "node": ">=12.0.0" + "node": ">=18.12" }, "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ts-loader/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ts-loader/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "typescript": ">=4.8.4" } }, - "node_modules/ts-loader/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-loader/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/ts-loader": { + "version": "9.5.7", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", + "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", "dev": true, "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, "engines": { - "node": ">=8" + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" } }, "node_modules/ts-loader/node_modules/source-map": { @@ -10376,19 +10923,6 @@ "node": ">= 12" } }, - "node_modules/ts-loader/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -10500,16 +11034,13 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/typed-rest-client": { @@ -10535,9 +11066,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10548,10 +11079,34 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true, "license": "MIT" }, @@ -10570,16 +11125,16 @@ } }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "dev": true, "license": "MIT" }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", "dev": true, "license": "MIT", "engines": { @@ -10587,12 +11142,25 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-filename": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", @@ -10619,6 +11187,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -10678,6 +11256,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "dev": true, "license": "MIT", "bin": { @@ -10706,6 +11285,30 @@ "dev": true, "license": "MIT" }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", @@ -10721,37 +11324,35 @@ } }, "node_modules/webpack": { - "version": "5.105.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", - "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", - "es-module-lexer": "^2.0.0", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", + "terser-webpack-plugin": "^5.5.0", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" + "webpack-sources": "^3.5.0" }, "bin": { "webpack": "bin/webpack.js" @@ -10770,19 +11371,15 @@ } }, "node_modules/webpack-cli": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", - "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.2.tgz", + "integrity": "sha512-dB0R4T+C/8YuvM+fabdvil6QE44/ChDXikV5lOOkrUeCkW5hTJv2pGLE3keh+D5hjYw8icBaJkZzpFoaHV4T+g==", "dev": true, "license": "MIT", "dependencies": { - "@discoveryjs/json-ext": "^0.6.1", - "@webpack-cli/configtest": "^3.0.1", - "@webpack-cli/info": "^3.0.1", - "@webpack-cli/serve": "^3.0.1", - "colorette": "^2.0.14", - "commander": "^12.1.0", - "cross-spawn": "^7.0.3", + "@discoveryjs/json-ext": "^1.0.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", "envinfo": "^7.14.0", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", @@ -10794,14 +11391,16 @@ "webpack-cli": "bin/cli.js" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.9.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.82.0" + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0" }, "peerDependenciesMeta": { "webpack-bundle-analyzer": { @@ -10813,13 +11412,13 @@ } }, "node_modules/webpack-cli/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/webpack-merge": { @@ -10838,9 +11437,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", "dev": true, "license": "MIT", "engines": { @@ -10871,6 +11470,16 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -10943,25 +11552,25 @@ "license": "MIT" }, "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true, "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -10986,104 +11595,50 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" } }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=8" } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/wrappy": { @@ -11091,7 +11646,8 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/write-file-atomic": { "version": "3.0.3", @@ -11171,9 +11727,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -11199,13 +11755,13 @@ } }, "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs-unparser": { @@ -11224,47 +11780,59 @@ "node": ">=10" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/yargs-unparser/node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/yauzl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.1.tgz", + "integrity": "sha512-RNPCUkiE/ZgO4w8i9U5yDQVHaFDdnzaFANElRvpJteCspvmv2VqrRb9lvS6odVD+jqI/zDsxAHJVsafpcheVQQ==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, "engines": { "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "node_modules/yauzl-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz", + "integrity": "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==", "dev": true, "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "@node-rs/crc32": "^1.7.0", + "is-it-type": "^5.1.2", + "simple-invariant": "^2.0.1" + }, + "engines": { + "node": ">=16" } }, "node_modules/yazl": { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index bd660e8..f0189ef 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "operator-terminals", "displayName": "Operator! Terminals for vscode", "description": "VS Code terminal integration for Operator! multi-agent orchestration", - "version": "0.1.31", + "version": "0.2.0", "publisher": "untra", "author": { "name": "Samuel Volin", @@ -15,7 +15,7 @@ }, "license": "MIT", "engines": { - "vscode": "^1.93.0" + "vscode": "^1.120.0" }, "categories": [ "Other" @@ -512,11 +512,12 @@ "watch": "npm run copy-types && tsc -watch -p ./", "watch:webview": "webpack --config webpack.webview.config.js --mode development --watch", "pretest": "npm run compile && npm run lint", - "lint": "eslint src --ext ts && eslint webview-ui --ext ts,tsx", + "lint": "eslint src webview-ui", "test": "vscode-test", "test:coverage": "node out/test/runTest.js", "package": "vsce package", "publish": "vsce publish", + "publish:ovsx": "ovsx publish", "generate:icons": "fantasticon" }, "dependencies": { @@ -526,36 +527,39 @@ "@mui/material": "^5.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "smol-toml": "^1.3.0" + "smol-toml": "^1.6.1" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", - "@stylistic/eslint-plugin-ts": "^2.13.0", + "@eslint/js": "^10.0.0", + "@stylistic/eslint-plugin-ts": "^4.4.1", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.6", - "@types/node": "20.x", + "@types/node": "25.x", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/sinon": "^17.0.3", - "@types/vscode": "^1.93.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vscode/test-cli": "^0.0.4", + "@types/vscode": "^1.120.0", + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.3.8", - "@vscode/vsce": "^2.22.0", - "c8": "^10.1.2", - "css-loader": "^7.1.0", - "eslint": "^8.56.0", + "@vscode/vsce": "^3.9.1", + "c8": "^11.0.0", + "css-loader": "^7.1.4", + "eslint": "^10.3.0", "fantasticon": "^4.1.0", - "glob": "^10.3.10", - "mocha": "^10.2.0", - "nyc": "^17.1.0", - "sinon": "^17.0.1", + "glob": "^13.0.6", + "mocha": "^11.7.5", + "nyc": "^18.0.0", + "ovsx": "^0.10.0", + "sinon": "^22.0.0", "source-map-support": "^0.5.21", "style-loader": "^4.0.0", - "ts-loader": "^9.5.0", - "typescript": "^5.3.3", - "webpack": "^5.97.0", - "webpack-cli": "^6.0.0" + "ts-loader": "^9.5.7", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", + "webpack": "^5.106.2", + "webpack-cli": "^7.0.2" } } diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index 751d760..ec34ad9 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -30,6 +30,7 @@ import type { WriteKanbanConfigResponse, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, + WorkflowExportResponse, } from './generated'; // Re-export generated types for consumers @@ -220,6 +221,27 @@ export class OperatorApiClient { return (await response.json()) as LaunchTicketResponse; } + /** + * Export a ticket (rendered against its issue type) to a Claude dynamic + * workflow (.js). Goes through the same shared code path as the CLI and TUI. + */ + async exportWorkflow(ticketId: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/tickets/${encodeURIComponent(ticketId)}/workflow-export`, + { method: 'POST' } + ); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + return (await response.json()) as WorkflowExportResponse; + } + /** * Pause queue processing * diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index a7a7b5e..48404db 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -181,11 +181,11 @@ export class ConfigPanel { const apiUrl = await discoverApiUrl(ticketsDir); const client = new OperatorApiClient(apiUrl); - let valid = false; let displayName = ''; let accountId = ''; let errorMsg: string | undefined; let projects: Array<{ key: string; name: string }> = []; + let valid: boolean; try { const result = await client.validateKanbanCredentials({ @@ -242,12 +242,12 @@ export class ConfigPanel { const apiUrl = await discoverApiUrl(ticketsDir); const client = new OperatorApiClient(apiUrl); - let valid = false; let userName = ''; let orgName = ''; let userId = ''; let teams: Array<{ id: string; name: string; key: string }> = []; let errorMsg: string | undefined; + let valid: boolean; try { const result = await client.validateKanbanCredentials({ @@ -618,14 +618,14 @@ async function readConfig(): Promise { let parsed: TomlConfig = {}; if (raw.trim()) { const { parse } = await importSmolToml(); - parsed = parse(raw) as TomlConfig; + parsed = parse(raw); } // Return the parsed TOML directly — field names already match generated types return { config_path: configPath || '', working_directory: workDir, - config: parsed as Record, + config: parsed, }; } @@ -653,7 +653,7 @@ async function writeConfigField( const { parse, stringify } = await importSmolToml(); let parsed: TomlConfig = {}; if (raw.trim()) { - parsed = parse(raw) as TomlConfig; + parsed = parse(raw); } // Apply the update based on section @@ -663,7 +663,7 @@ async function writeConfigField( // Update VS Code setting, not the TOML file await vscode.workspace .getConfiguration('operator') - .update('workingDirectory', value as string, vscode.ConfigurationTarget.Global); + .update('workingDirectory', value, vscode.ConfigurationTarget.Global); return; // Don't write to TOML } parsed[key] = value; @@ -792,7 +792,7 @@ async function writeConfigField( break; } - const output = stringify(parsed as Record); + const output = stringify(parsed); await fs.writeFile(configPath, output, 'utf-8'); } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 3d7704f..f2f00e6 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -247,7 +247,7 @@ async function launchTicketFromEditorWithOptionsCommand( const ticketType = ctx.issueTypeService.extractTypeFromId(metadata.id); const ticketStatus = (metadata.status === 'in-progress' || metadata.status === 'completed') - ? metadata.status as 'in-progress' | 'completed' + ? metadata.status : 'queue' as const; const ticketInfo: TicketInfo = { id: metadata.id, diff --git a/vscode-extension/src/issuetype-service.ts b/vscode-extension/src/issuetype-service.ts index 9c2450f..af8239e 100644 --- a/vscode-extension/src/issuetype-service.ts +++ b/vscode-extension/src/issuetype-service.ts @@ -159,7 +159,7 @@ export class IssueTypeService { this.outputChannel.appendLine( `[IssueTypeService] Loaded ${data.length} issue types from API` ); - } catch (error) { + } catch { // API not available - keep using defaults this.outputChannel.appendLine( `[IssueTypeService] API unavailable, using ${this.types.size} default types` diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index bcbefd8..f4f5b8e 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -2,15 +2,43 @@ * MCP connection logic for Operator VS Code extension. * * Discovers the local Operator API, fetches the MCP descriptor, - * and registers the Operator MCP server in VS Code workspace settings. + * and registers the Operator MCP server. The registration path depends on + * the host IDE: + * + * - **VS Code (and other Code OSS forks without a special MCP path)** — + * writes a workspace-scope `mcp.servers.operator` entry via + * `vscode.workspace.getConfiguration('mcp').update('servers', ...)`. When + * the operator descriptor advertises stdio, the entry uses the stdio shape; + * otherwise it falls back to SSE (preserves existing behavior). + * + * - **Cursor** — writes a user-scope entry to `~/.cursor/mcp.json` under + * `mcpServers.operator`. Cursor's MCP UI surfaces this user-scope config, + * not VS Code's workspace `mcp.servers`. Stdio-only (Cursor's `mcpServers` + * shape does not support an SSE URL); errors out with an actionable message + * when the operator descriptor does not advertise stdio. */ import * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; import { discoverApiUrl } from './api-client'; +/** + * Stdio entrypoint advertised by the Operator MCP descriptor when + * `[mcp].stdio_advertised = true` is set in the operator config. + * Matches the Rust `StdioCommand` DTO. + */ +export interface StdioCommand { + command: string; + args: string[]; + cwd: string; +} + /** * MCP server descriptor returned by the Operator API. - * Matches the Rust McpDescriptorResponse DTO. + * Matches the Rust McpDescriptorResponse DTO. The `stdio` field is omitted + * when the operator config has `[mcp].stdio_advertised = false`. */ export interface McpDescriptorResponse { server_name: string; @@ -19,6 +47,52 @@ export interface McpDescriptorResponse { transport_url: string; label: string; openapi_url: string | null; + stdio?: StdioCommand; +} + +/** Host IDE branches the extension knows how to register MCP servers in. */ +export type HostApp = 'cursor' | 'vscode' | 'other'; + +/** + * Indirection layer for the small pieces of platform state that need to be + * stubbed across function boundaries in tests. Sinon stubs cannot intercept + * intra-file direct calls, so `connectMcpServer` and the registration + * functions invoke these via `_testable.fn()` to allow stubbing. + */ +export const _testable = { + /** + * Returns `vscode.env.appName` (or "" if unavailable). The `vscode-test` + * electron host returns its own string, so production code must NOT + * branch on the raw value — go through `detectHostApp()` below. + * + * Observed values: + * - Stock VS Code: "Visual Studio Code" (or "Visual Studio Code - Insiders") + * - Cursor: "Cursor" (verify at runtime — see cursor.md Pre-Flight) + */ + rawAppName(): string { + return vscode.env.appName ?? ''; + }, + /** Default location of Cursor's user-scope MCP config. */ + cursorMcpConfigPath(): string { + return path.join(os.homedir(), '.cursor', 'mcp.json'); + }, +}; + +/** Detect which IDE the extension is running inside. */ +export function detectHostApp(): HostApp { + const name = _testable.rawAppName(); + if (name.startsWith('Cursor')) { + return 'cursor'; + } + if (name.startsWith('Visual Studio Code')) { + return 'vscode'; + } + return 'other'; +} + +/** Public accessor for the default Cursor MCP config path. */ +export function cursorMcpConfigPath(): string { + return _testable.cursorMcpConfigPath(); } /** @@ -38,7 +112,8 @@ export async function fetchMcpDescriptor( response = await fetch(url); } catch (err) { throw new Error( - `Operator API is not running at ${apiUrl}. Start the server first.` + `Operator API is not running at ${apiUrl}. Start the server first.`, + { cause: err }, ); } @@ -54,7 +129,8 @@ export async function fetchMcpDescriptor( /** * Check whether an MCP server named "operator" is already registered - * in VS Code workspace settings. + * in VS Code workspace settings. (Cursor users should check + * `~/.cursor/mcp.json` directly — VS Code's API does not see that file.) */ export function isMcpServerRegistered(): boolean { const mcpConfig = vscode.workspace.getConfiguration('mcp'); @@ -63,20 +139,143 @@ export function isMcpServerRegistered(): boolean { } /** - * Connect Operator as an MCP server in VS Code. + * Build the workspace-scope server entry for VS Code's `mcp.servers`, + * preferring the stdio transport when the descriptor advertises it. + */ +function buildVscodeServerEntry( + descriptor: McpDescriptorResponse +): Record { + if (descriptor.stdio) { + return { + type: 'stdio', + command: descriptor.stdio.command, + args: descriptor.stdio.args, + cwd: descriptor.stdio.cwd, + }; + } + return { + type: 'sse', + url: descriptor.transport_url, + }; +} + +/** + * Register Operator under VS Code's workspace `mcp.servers` setting. * - * Discovers the running API, fetches the MCP descriptor, - * and writes the server config into VS Code workspace settings - * under the `mcp.servers` key. + * Prefers the stdio shape when the descriptor advertises it; otherwise + * preserves the legacy SSE registration so old operator builds keep working. + */ +export async function registerInVscodeWorkspaceConfig( + descriptor: McpDescriptorResponse +): Promise { + const mcpConfig = vscode.workspace.getConfiguration('mcp'); + const servers = mcpConfig.get>('servers') || {}; + + servers['operator'] = buildVscodeServerEntry(descriptor); + + await mcpConfig.update( + 'servers', + servers, + vscode.ConfigurationTarget.Workspace + ); + + const transport = descriptor.stdio ? 'stdio' : 'sse'; + const detail = descriptor.stdio + ? `${descriptor.stdio.command} ${descriptor.stdio.args.join(' ')}` + : descriptor.transport_url; + void vscode.window.showInformationMessage( + `Operator MCP server registered (${transport}: ${detail})` + ); +} + +/** + * Register Operator in Cursor's user-scope MCP config (`~/.cursor/mcp.json`). + * + * Stdio-only: Cursor's `mcpServers` shape does not accept an SSE URL. If the + * descriptor does not advertise stdio, this function shows an actionable + * error naming the `[mcp].stdio_advertised` config knob and returns without + * touching the file. + * + * Merge semantics: any existing top-level keys and any existing + * `mcpServers.*` entries are preserved; only the `mcpServers.operator` + * key is set/overwritten. Bails on JSON parse failure rather than + * overwriting a file the user has hand-edited. + * + * @param configPath - Override target file (tests pass a tempdir path). + * Defaults to `cursorMcpConfigPath()`. + */ +export async function registerInCursorUserConfig( + descriptor: McpDescriptorResponse, + configPath: string = _testable.cursorMcpConfigPath() +): Promise { + if (!descriptor.stdio) { + void vscode.window.showErrorMessage( + 'Operator MCP stdio entrypoint is not advertised. Set ' + + '`[mcp].stdio_advertised = true` in your operator config and restart ' + + 'the API, or use stock VS Code which can connect over SSE.' + ); + return; + } + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existing: Record = {}; + try { + const raw = await fs.readFile(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed !== null && typeof parsed === 'object') { + existing = parsed as Record; + } + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code !== 'ENOENT') { + void vscode.window.showErrorMessage( + `Could not parse existing ${configPath}: ${e.message}. ` + + 'Please fix or remove the file and retry.' + ); + return; + } + } + + const existingServers = existing.mcpServers; + const mcpServers = + existingServers && typeof existingServers === 'object' + ? { ...(existingServers as Record) } + : {}; + + mcpServers['operator'] = { + command: descriptor.stdio.command, + args: descriptor.stdio.args, + cwd: descriptor.stdio.cwd, + }; + + const merged = { ...existing, mcpServers }; + await fs.writeFile( + configPath, + JSON.stringify(merged, null, 2) + '\n', + 'utf-8' + ); + + void vscode.window.showInformationMessage( + `Operator MCP server registered in ${configPath} (stdio). ` + + 'You may need to restart Cursor or toggle the server in ' + + 'Cursor Settings → MCP.' + ); +} + +/** + * Connect Operator as an MCP server in the host IDE. + * + * Discovers the running API, fetches the descriptor, then dispatches to + * either the Cursor user-scope path (`~/.cursor/mcp.json`) or the VS Code + * workspace-scope path (`mcp.servers`) based on the detected host. */ export async function connectMcpServer( ticketsDir: string | undefined ): Promise { try { - // 1. Discover the API URL const apiUrl = await discoverApiUrl(ticketsDir); - // 2. Fetch the MCP descriptor let descriptor: McpDescriptorResponse; try { descriptor = await fetchMcpDescriptor(apiUrl); @@ -87,24 +286,11 @@ export async function connectMcpServer( return; } - // 3. Write MCP server config to workspace settings - const mcpConfig = vscode.workspace.getConfiguration('mcp'); - const servers = mcpConfig.get>('servers') || {}; - - servers['operator'] = { - type: 'sse', - url: descriptor.transport_url, - }; - - await mcpConfig.update( - 'servers', - servers, - vscode.ConfigurationTarget.Workspace - ); - - void vscode.window.showInformationMessage( - `Operator MCP server registered (${descriptor.transport_url})` - ); + if (detectHostApp() === 'cursor') { + await registerInCursorUserConfig(descriptor); + } else { + await registerInVscodeWorkspaceConfig(descriptor); + } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect MCP server'; diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index 9def1ab..055238f 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -18,6 +18,7 @@ export class ConnectionsSection implements StatusSection { private operatorVersion: string | undefined; private mcpRegistered: boolean = false; private wrapperType: string = 'vscode'; + private webUiAvailable: boolean = false; get isApiConnected(): boolean { return this.apiStatus.connected; @@ -139,15 +140,26 @@ export class ConnectionsSection implements StatusSection { port: port ? parseInt(port, 10) : 7008, url: apiUrl, }; + await this.checkWebUi(apiUrl); return true; } } catch { // Health check failed } this.apiStatus = { connected: false }; + this.webUiAvailable = false; return false; } + private async checkWebUi(apiUrl: string): Promise { + try { + const res = await fetch(apiUrl); + this.webUiAvailable = res.ok && (res.headers.get('content-type') || '').includes('text/html'); + } catch { + this.webUiAvailable = false; + } + } + getTopLevelItem(ctx: SectionContext): StatusItem { return new StatusItem({ label: 'Connections', @@ -234,7 +246,31 @@ export class ConnectionsSection implements StatusSection { sectionId: this.sectionId, }); - // 4. Webhook Connection + // 4. Web UI + const webUiItem = this.webUiAvailable + ? new StatusItem({ + label: 'Web UI', + description: `http://localhost:${this.apiStatus.port || 7008}`, + icon: 'pass', + tooltip: 'Click to open the embedded web UI in browser', + command: { + command: 'vscode.open', + title: 'Open Web UI', + arguments: [vscode.Uri.parse(`http://localhost:${this.apiStatus.port || 7008}`)], + }, + sectionId: this.sectionId, + }) + : new StatusItem({ + label: 'Web UI', + description: this.apiStatus.connected ? 'Not available' : 'API required', + icon: 'circle-slash', + tooltip: this.apiStatus.connected + ? 'The embedded web UI requires the binary to be built with --features embed-ui' + : 'Start the Operator API to enable the web UI', + sectionId: this.sectionId, + }); + + // 5. Webhook Connection const webhookItem = this.webhookStatus.running ? new StatusItem({ label: 'Webhook', @@ -293,7 +329,7 @@ export class ConnectionsSection implements StatusSection { }); } - return [wrapperItem, versionItem, apiItem, webhookItem, mcpItem]; + return [wrapperItem, versionItem, apiItem, webUiItem, webhookItem, mcpItem]; } private getConnectionsSummary(): string { diff --git a/vscode-extension/src/sections/git-section.ts b/vscode-extension/src/sections/git-section.ts index 6650b20..d9da94a 100644 --- a/vscode-extension/src/sections/git-section.ts +++ b/vscode-extension/src/sections/git-section.ts @@ -45,7 +45,7 @@ export class GitSection implements StatusSection { const useWorktrees = gitSection.use_worktrees as boolean | undefined; // Determine token status based on active provider - let tokenSet = false; + let tokenSet: boolean; if (provider === 'gitlab' || gitlabEnabled) { const tokenEnv = (gitlab?.token_env as string) || 'GITLAB_TOKEN'; tokenSet = !!process.env[tokenEnv]; diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index 5e47d02..6c5fa80 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -78,6 +78,9 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.modelServerSection = new ModelServerSection(); this.managedProjectsSection = new ManagedProjectsSection(); + // Canonical section order — must match the `SectionId` enum in + // src/ui/status_panel.rs (the single source of truth) and the TUI's + // section registry, so all three surfaces stay in sync. this.allSections = [ this.configSection, this.connectionsSection, @@ -138,7 +141,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { const raw = await fs.readFile(configPath, 'utf-8'); if (raw.trim()) { const { parse } = await importSmolToml(); - this.parsedConfig = parse(raw) as Record; + this.parsedConfig = parse(raw); } else { this.parsedConfig = {}; } diff --git a/vscode-extension/src/webhook-server.ts b/vscode-extension/src/webhook-server.ts index 8abebcc..e531227 100644 --- a/vscode-extension/src/webhook-server.ts +++ b/vscode-extension/src/webhook-server.ts @@ -22,7 +22,7 @@ import { SessionInfo, } from './types'; -const VERSION = '0.1.31'; +const VERSION = '0.2.0'; /** * HTTP server for operator <-> extension communication diff --git a/vscode-extension/test/fixtures/api/mcp-descriptor-response-stdio.json b/vscode-extension/test/fixtures/api/mcp-descriptor-response-stdio.json new file mode 100644 index 0000000..f11b6da --- /dev/null +++ b/vscode-extension/test/fixtures/api/mcp-descriptor-response-stdio.json @@ -0,0 +1,13 @@ +{ + "server_name": "operator", + "server_id": "operator-mcp", + "version": "0.1.32", + "transport_url": "http://localhost:7008/api/v1/mcp/sse", + "label": "Operator MCP Server", + "openapi_url": "http://localhost:7008/api-docs/openapi.json", + "stdio": { + "command": "/usr/local/bin/operator", + "args": ["mcp"], + "cwd": "/Users/dev/work" + } +} diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index a085901..ec27021 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -1,19 +1,32 @@ /** * Tests for mcp-connect.ts * - * Tests MCP descriptor fetching and server registration check. + * Covers: + * - `fetchMcpDescriptor` HTTP behavior + * - `detectHostApp` IDE branch detection + * - `registerInVscodeWorkspaceConfig` (stdio-preferred, SSE fallback) + * - `registerInCursorUserConfig` (~/.cursor/mcp.json merge semantics) + * - `connectMcpServer` end-to-end dispatch */ import * as assert from 'assert'; import * as sinon from 'sinon'; +import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import * as path from 'path'; +import * as os from 'os'; +import * as mcpConnect from '../../src/mcp-connect'; +import * as apiClient from '../../src/api-client'; import { fetchMcpDescriptor, + detectHostApp, + registerInCursorUserConfig, + registerInVscodeWorkspaceConfig, + connectMcpServer, + _testable, McpDescriptorResponse, } from '../../src/mcp-connect'; -// Path to fixtures const fixturesDir = path.join( __dirname, '..', @@ -24,6 +37,12 @@ const fixturesDir = path.join( 'api' ); +async function loadFixture(name: string): Promise { + return JSON.parse( + await fs.readFile(path.join(fixturesDir, name), 'utf-8') + ) as McpDescriptorResponse; +} + suite('MCP Connect Test Suite', () => { let fetchStub: sinon.SinonStub; @@ -37,12 +56,9 @@ suite('MCP Connect Test Suite', () => { suite('fetchMcpDescriptor()', () => { test('fetches descriptor from correct URL', async () => { - const descriptorResponse: McpDescriptorResponse = JSON.parse( - await fs.readFile( - path.join(fixturesDir, 'mcp-descriptor-response.json'), - 'utf-8' - ) - ) as McpDescriptorResponse; + const descriptorResponse = await loadFixture( + 'mcp-descriptor-response.json' + ); fetchStub.resolves( new Response(JSON.stringify(descriptorResponse), { status: 200 }) @@ -114,5 +130,370 @@ suite('MCP Connect Test Suite', () => { 'http://localhost:9999/api/v1/mcp/descriptor' ); }); + + test('parses stdio field when present', async () => { + const descriptorResponse = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + fetchStub.resolves( + new Response(JSON.stringify(descriptorResponse), { status: 200 }) + ); + + const result = await fetchMcpDescriptor('http://localhost:7008'); + + assert.ok(result.stdio, 'stdio field should be populated'); + assert.strictEqual(result.stdio?.command, '/usr/local/bin/operator'); + assert.deepStrictEqual(result.stdio?.args, ['mcp']); + assert.strictEqual(result.stdio?.cwd, '/Users/dev/work'); + }); + }); + + suite('detectHostApp()', () => { + let rawAppNameStub: sinon.SinonStub; + + setup(() => { + rawAppNameStub = sinon.stub(_testable, 'rawAppName'); + }); + + test("returns 'cursor' for exact 'Cursor'", () => { + rawAppNameStub.returns('Cursor'); + assert.strictEqual(detectHostApp(), 'cursor'); + }); + + test("returns 'cursor' for 'Cursor (Anysphere)'", () => { + rawAppNameStub.returns('Cursor (Anysphere)'); + assert.strictEqual(detectHostApp(), 'cursor'); + }); + + test("returns 'vscode' for 'Visual Studio Code'", () => { + rawAppNameStub.returns('Visual Studio Code'); + assert.strictEqual(detectHostApp(), 'vscode'); + }); + + test("returns 'vscode' for 'Visual Studio Code - Insiders'", () => { + rawAppNameStub.returns('Visual Studio Code - Insiders'); + assert.strictEqual(detectHostApp(), 'vscode'); + }); + + test("returns 'other' for empty string", () => { + rawAppNameStub.returns(''); + assert.strictEqual(detectHostApp(), 'other'); + }); + + test("returns 'other' for unknown host (e.g. 'Theia IDE')", () => { + rawAppNameStub.returns('Theia IDE'); + assert.strictEqual(detectHostApp(), 'other'); + }); + }); + + suite('registerInCursorUserConfig()', () => { + let tmpDir: string; + let cursorConfigPath: string; + let infoStub: sinon.SinonStub; + let errorStub: sinon.SinonStub; + + setup(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'op-cursor-test-')); + cursorConfigPath = path.join(tmpDir, '.cursor', 'mcp.json'); + infoStub = sinon.stub(vscode.window, 'showInformationMessage'); + errorStub = sinon.stub(vscode.window, 'showErrorMessage'); + }); + + teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test('creates ~/.cursor directory when missing', async () => { + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInCursorUserConfig(descriptor, cursorConfigPath); + + const stat = await fs.stat(path.dirname(cursorConfigPath)); + assert.ok(stat.isDirectory(), '~/.cursor should exist'); + }); + + test('writes mcpServers.operator with stdio shape', async () => { + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInCursorUserConfig(descriptor, cursorConfigPath); + + const raw = await fs.readFile(cursorConfigPath, 'utf-8'); + const parsed = JSON.parse(raw) as { + mcpServers: Record; + }; + assert.deepStrictEqual(parsed.mcpServers.operator, { + command: '/usr/local/bin/operator', + args: ['mcp'], + cwd: '/Users/dev/work', + }); + assert.ok( + infoStub.calledOnce, + 'showInformationMessage should be called' + ); + }); + + test('preserves existing mcpServers.* entries during merge', async () => { + await fs.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fs.writeFile( + cursorConfigPath, + JSON.stringify({ + mcpServers: { + 'other-server': { command: '/usr/bin/other', args: [] }, + }, + }), + 'utf-8' + ); + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInCursorUserConfig(descriptor, cursorConfigPath); + + const parsed = JSON.parse( + await fs.readFile(cursorConfigPath, 'utf-8') + ) as { mcpServers: Record }; + assert.ok( + parsed.mcpServers['other-server'], + 'existing other-server should survive' + ); + assert.ok( + parsed.mcpServers['operator'], + 'new operator entry should be written' + ); + }); + + test('preserves other top-level keys during merge', async () => { + await fs.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fs.writeFile( + cursorConfigPath, + JSON.stringify({ + customKey: { foo: 'bar' }, + mcpServers: {}, + }), + 'utf-8' + ); + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInCursorUserConfig(descriptor, cursorConfigPath); + + const parsed = JSON.parse( + await fs.readFile(cursorConfigPath, 'utf-8') + ) as { customKey: { foo: string }; mcpServers: Record }; + assert.deepStrictEqual(parsed.customKey, { foo: 'bar' }); + assert.ok(parsed.mcpServers['operator']); + }); + + test('shows error and skips write when descriptor lacks stdio', async () => { + const descriptor: McpDescriptorResponse = { + server_name: 'operator', + server_id: 'operator-mcp', + version: '0.1.32', + transport_url: 'http://localhost:7008/api/v1/mcp/sse', + label: 'Operator MCP Server', + openapi_url: null, + // no stdio + }; + + await registerInCursorUserConfig(descriptor, cursorConfigPath); + + assert.ok(errorStub.calledOnce, 'error message should be shown'); + const errorMsg = errorStub.firstCall.args[0] as string; + assert.ok( + errorMsg.includes('stdio_advertised'), + 'error should name the config knob' + ); + await assert.rejects( + () => fs.access(cursorConfigPath), + 'mcp.json should NOT have been written' + ); + }); + + test('shows error when existing mcp.json is malformed JSON', async () => { + await fs.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fs.writeFile(cursorConfigPath, '{not valid json', 'utf-8'); + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInCursorUserConfig(descriptor, cursorConfigPath); + + assert.ok(errorStub.calledOnce, 'error message should be shown'); + const errorMsg = errorStub.firstCall.args[0] as string; + assert.ok( + errorMsg.includes('Could not parse'), + 'error should mention parse failure' + ); + // file should be unchanged (still malformed) + const raw = await fs.readFile(cursorConfigPath, 'utf-8'); + assert.strictEqual(raw, '{not valid json'); + }); + }); + + suite('registerInVscodeWorkspaceConfig()', () => { + let configUpdateStub: sinon.SinonStub; + let infoStub: sinon.SinonStub; + let getStub: sinon.SinonStub; + + setup(() => { + configUpdateStub = sinon.stub().resolves(); + getStub = sinon.stub().returns({}); + sinon.stub(vscode.workspace, 'getConfiguration').returns({ + get: getStub, + update: configUpdateStub, + } as unknown as vscode.WorkspaceConfiguration); + infoStub = sinon.stub(vscode.window, 'showInformationMessage'); + }); + + test('writes stdio entry when descriptor.stdio is present', async () => { + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInVscodeWorkspaceConfig(descriptor); + + assert.ok(configUpdateStub.calledOnce); + assert.strictEqual(configUpdateStub.firstCall.args[0], 'servers'); + const written = configUpdateStub.firstCall.args[1] as Record< + string, + Record + >; + assert.deepStrictEqual(written.operator, { + type: 'stdio', + command: '/usr/local/bin/operator', + args: ['mcp'], + cwd: '/Users/dev/work', + }); + assert.ok(infoStub.calledOnce); + }); + + test('writes sse entry when descriptor.stdio is absent', async () => { + const descriptor = await loadFixture( + 'mcp-descriptor-response.json' + ); + + await registerInVscodeWorkspaceConfig(descriptor); + + const written = configUpdateStub.firstCall.args[1] as Record< + string, + Record + >; + assert.deepStrictEqual(written.operator, { + type: 'sse', + url: 'http://localhost:7008/api/v1/mcp/sse', + }); + }); + + test('preserves existing mcp.servers entries during merge', async () => { + getStub.returns({ + 'other-server': { type: 'sse', url: 'http://other' }, + }); + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + + await registerInVscodeWorkspaceConfig(descriptor); + + const written = configUpdateStub.firstCall.args[1] as Record< + string, + Record + >; + assert.ok(written['other-server']); + assert.ok(written.operator); + }); + }); + + suite('connectMcpServer() dispatch', () => { + let detectHostStub: sinon.SinonStub; + let discoverApiUrlStub: sinon.SinonStub; + let configUpdateStub: sinon.SinonStub; + let getStub: sinon.SinonStub; + + setup(() => { + detectHostStub = sinon.stub(_testable, 'rawAppName'); + discoverApiUrlStub = sinon + .stub(apiClient, 'discoverApiUrl') + .resolves('http://localhost:7008'); + configUpdateStub = sinon.stub().resolves(); + getStub = sinon.stub().returns({}); + sinon.stub(vscode.workspace, 'getConfiguration').returns({ + get: getStub, + update: configUpdateStub, + } as unknown as vscode.WorkspaceConfiguration); + sinon.stub(vscode.window, 'showInformationMessage'); + sinon.stub(vscode.window, 'showErrorMessage'); + }); + + test('VS Code host writes to workspace mcp.servers', async () => { + detectHostStub.returns('Visual Studio Code'); + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + fetchStub.resolves( + new Response(JSON.stringify(descriptor), { status: 200 }) + ); + + await connectMcpServer(undefined); + + assert.ok(discoverApiUrlStub.calledOnce); + assert.ok( + configUpdateStub.calledOnce, + 'VS Code path should write workspace config' + ); + const written = configUpdateStub.firstCall.args[1] as Record< + string, + Record | undefined + >; + assert.ok(written.operator, 'operator entry should be written'); + assert.strictEqual(written.operator.type, 'stdio'); + }); + + test('Cursor host does NOT write to workspace mcp.servers', async () => { + detectHostStub.returns('Cursor'); + // Re-stub cursorMcpConfigPath to a tmp path so the test doesn't + // mutate the developer's real ~/.cursor/mcp.json. + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'op-cursor-dispatch-') + ); + const tmpConfigPath = path.join(tmpDir, '.cursor', 'mcp.json'); + sinon.stub(_testable, 'cursorMcpConfigPath').returns(tmpConfigPath); + + try { + const descriptor = await loadFixture( + 'mcp-descriptor-response-stdio.json' + ); + fetchStub.resolves( + new Response(JSON.stringify(descriptor), { status: 200 }) + ); + + await connectMcpServer(undefined); + + assert.ok( + configUpdateStub.notCalled, + 'Cursor path should NOT touch workspace mcp.servers' + ); + // and the cursor config file should exist + const raw = await fs.readFile(tmpConfigPath, 'utf-8'); + const parsed = JSON.parse(raw) as { + mcpServers: { operator: { command: string } }; + }; + assert.strictEqual( + parsed.mcpServers.operator.command, + '/usr/local/bin/operator' + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); }); + +// Silence unused-import warning — `mcpConnect` is imported for documentation +// (it shows the public surface tests rely on). +void mcpConnect; diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json index b8fb97a..94bdd96 100644 --- a/vscode-extension/tsconfig.json +++ b/vscode-extension/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "module": "Node16", "target": "ES2022", + "types": ["node"], "lib": ["ES2022"], "sourceMap": true, "outDir": "out", diff --git a/vscode-extension/webview-ui/.eslintrc.json b/vscode-extension/webview-ui/.eslintrc.json deleted file mode 100644 index ef50a42..0000000 --- a/vscode-extension/webview-ui/.eslintrc.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "ecmaFeatures": { "jsx": true } - }, - "plugins": ["@typescript-eslint"], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "../../.eslintrc.base.json" - ], - "env": { "browser": true, "es2022": true }, - "rules": { - "@typescript-eslint/consistent-type-assertions": ["error", { - "assertionStyle": "as", - "objectLiteralTypeAssertions": "never" - }], - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/naming-convention": ["warn", { - "selector": "import", - "format": ["camelCase", "PascalCase"] - }], - "@typescript-eslint/semi": "warn", - "semi": "off" - } -} diff --git a/vscode-extension/webview-ui/css.d.ts b/vscode-extension/webview-ui/css.d.ts new file mode 100644 index 0000000..35306c6 --- /dev/null +++ b/vscode-extension/webview-ui/css.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index badae8c..57fc096 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -7,6 +7,7 @@ const DEFAULT_CONFIG: Config = { agents: { max_parallel: 2, cores_reserved: 1, + max_agents_per_repo: 1, health_check_interval: BigInt(30), generation_timeout_secs: BigInt(300), sync_interval: BigInt(60), @@ -98,28 +99,6 @@ const DEFAULT_CONFIG: Config = { default_model: null, skill_directory_overrides: {}, }, - backstage: { - enabled: false, - display: false, - port: 7009, - auto_start: false, - subpath: '/backstage', - branding_subpath: '/branding', - release_url: '', - local_binary_path: null, - branding: { - app_title: 'Operator', - org_name: '', - logo_path: null, - colors: { - primary: '#4f46e5', - secondary: '#7c3aed', - accent: '#06b6d4', - warning: '#f59e0b', - muted: '#6b7280', - }, - }, - }, rest_api: { enabled: false, port: 7008, @@ -145,6 +124,17 @@ const DEFAULT_CONFIG: Config = { delegators: [], model_servers: [], relay: { auto_inject_mcp: false }, + mcp: { + http_enabled: true, + stdio_advertised: true, + expose_ticket_write_tools: false, + external_servers: [], + }, + acp: { + stdio_advertised: true, + default_delegator: null, + max_concurrent_sessions: 4, + }, }; export const DEFAULT_WEBVIEW_CONFIG: WebviewConfig = { diff --git a/zed-extension/Cargo.lock b/zed-extension/Cargo.lock index fd3533e..9740c85 100644 --- a/zed-extension/Cargo.lock +++ b/zed-extension/Cargo.lock @@ -367,7 +367,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "operator-zed" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/zed-extension/Cargo.toml b/zed-extension/Cargo.toml index 533b8ce..ba8b0c5 100644 --- a/zed-extension/Cargo.toml +++ b/zed-extension/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "operator-zed" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Zed extension for Operator multi-agent orchestration" license = "MIT" diff --git a/zed-extension/README.md b/zed-extension/README.md index 1927477..fa5cbc0 100644 --- a/zed-extension/README.md +++ b/zed-extension/README.md @@ -1,177 +1,143 @@ # Operator Zed Extension -Zed extension providing slash commands for interacting with [Operator](https://operator.untra.io), a multi-agent orchestration system for Claude Code. +Zed extension for [Operator](https://operator.untra.io), a multi-agent orchestration system for Claude Code. Provides three integration layers: -## Features - -This extension adds 11 slash commands to Zed's AI assistant for managing Operator: - -| Command | Description | -|---------|-------------| -| `/op-status` | Show Operator health and status | -| `/op-queue` | List tickets in queue | -| `/op-launch TICKET-ID` | Launch a ticket | -| `/op-active` | List active agents | -| `/op-completed` | List recently completed tickets | -| `/op-ticket TICKET-ID` | Show ticket details | -| `/op-pause` | Pause queue processing | -| `/op-resume` | Resume queue processing | -| `/op-sync` | Sync kanban collections | -| `/op-approve AGENT-ID` | Approve agent review | -| `/op-reject AGENT-ID REASON` | Reject agent review | +1. **MCP Context Server** — registers `operator mcp` natively so all operator tools and ticket resources appear in Zed's Agent Panel +2. **ACP Agent Setup** — `/op-setup-agent` generates the config to register Operator as an ACP agent server +3. **Slash Commands** — thin AI/human inference layer for quick operations with tab completion ## Prerequisites -- [Operator](https://operator.untra.io) installed and running (`operator api`) -- Rust toolchain with `wasm32-wasip1` target +- [Operator](https://operator.untra.io) binary installed and on PATH +- Operator API server running (`operator api`) - Zed editor -### Installing Rust WASM Target +## Installation + +### Download Operator binary + +Download the latest binary for your platform from [GitHub releases](https://github.com/untra/operator/releases/latest): +**macOS (Apple Silicon):** ```bash -rustup target add wasm32-wasip1 +curl -L https://github.com/untra/operator/releases/latest/download/operator-macos-arm64 -o /usr/local/bin/operator && chmod +x /usr/local/bin/operator ``` -## Building - +**Linux (x86_64):** ```bash -cd zed-extension -cargo build --release --target wasm32-wasip1 +curl -L https://github.com/untra/operator/releases/latest/download/operator-linux-x86_64 -o /usr/local/bin/operator && chmod +x /usr/local/bin/operator ``` -The compiled extension will be at `target/wasm32-wasip1/release/operator_zed.wasm`. +Or build from source: `cargo install --git https://github.com/untra/operator` -## Installation (Development) +### Install the Zed extension -1. Build the extension: - ```bash - cargo build --release --target wasm32-wasip1 - ``` +Install from the Zed extension marketplace, or for development: -2. Create a dev extension directory in Zed's extensions folder: - ```bash - mkdir -p ~/.local/share/zed/extensions/installed/operator-dev - ``` +```bash +# Install WASM target +rustup target add wasm32-wasip1 -3. Copy the extension files: - ```bash - cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/ - cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm - ``` +# Build +cd zed-extension +cargo build --release --target wasm32-wasip1 -4. Restart Zed or use **Extensions: Reload** command +# Install as dev extension +mkdir -p ~/.local/share/zed/extensions/installed/operator-dev +cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/ +cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm -## Usage +# Restart Zed or use Extensions: Reload +``` -1. Start the Operator API server: - ```bash - operator api - ``` +### Verify setup -2. Open Zed's AI assistant panel (Cmd+Shift+A or Ctrl+Shift+A) +Start the Operator server and check your connection: -3. Type a slash command to interact with Operator: - ``` - /op-status - ``` +```bash +operator api # start the REST API server +``` -4. Commands with arguments support autocompletion: - ``` - /op-launch FIX- # Tab to autocomplete ticket IDs - ``` +Then run `/op-setup` in the Zed assistant to verify everything is connected. -### Example Workflow +## Setup -``` -User: /op-status -Assistant: [Shows Operator status with queue count, active agents, etc.] +### MCP Context Server (automatic) -User: /op-queue -Assistant: [Lists tickets in queue with ID, project, type, title] +After installing the extension, Zed automatically launches `operator mcp` as a context server. All MCP tools appear in the Agent Panel: -User: /op-launch FIX-123 -Assistant: [Launches the ticket and shows the command to run] +- `operator_health` / `operator_status` — system health +- `operator_list_tickets` — query queue, in-progress, completed tickets +- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` — ticket lifecycle +- `operator_create_ticket` — create tickets from templates +- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` — registry queries +- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` — queue operations +- `operator_approve_agent` / `operator_reject_agent` — review actions -User: /op-active -Assistant: [Shows running agents with their status] -``` +If the `operator` binary is not found, the extension shows installation instructions. -## Architecture +### ACP Agent Server (one-time setup) -### WASM Sandbox Limitations +Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel — you can send prompts that flow through ACP to a Claude Code delegator. -Zed extensions run in a WebAssembly sandbox with limited capabilities: +## Slash Commands -- **No native HTTP**: We use `curl` subprocess calls to communicate with the Operator REST API -- **No sidebar views**: UI is limited to slash command output in the AI assistant -- **No status bar**: Cannot show persistent status indicators -- **No webhooks**: Cannot receive callbacks from Operator +| Command | Description | +|---------|-------------| +| `/op-setup` | Check installation and connection status | +| `/op-help` | List all available commands | +| `/op-status` | Show Operator health and status | +| `/op-queue` | List tickets in queue | +| `/op-launch TICKET-ID` | Launch a ticket | +| `/op-active` | List active agents | +| `/op-completed` | List recently completed tickets | +| `/op-ticket TICKET-ID` | Show ticket details | +| `/op-pause` | Pause queue processing | +| `/op-resume` | Resume queue processing | +| `/op-sync` | Sync kanban collections | +| `/op-approve AGENT-ID` | Approve agent review | +| `/op-reject AGENT-ID REASON` | Reject agent review | +| `/op-setup-agent` | Generate ACP agent server config | + +Commands with arguments support tab-completion from live API data. -### Communication Flow +## Architecture ``` -Zed AI Assistant +Zed Agent Panel │ - ├──[slash command]──▶ Extension (WASM) - │ │ - │ ├──[subprocess]──▶ curl - │ │ │ - │ │ ▼ - │ │ Operator API - │ │ (localhost:7008) - │ │ │ - │ ◀──[JSON response]───┘ - │ │ - ◀──[markdown output]──────┘ -``` - -## Alternative: Tasks - -For actions that require a terminal, you can configure Zed tasks in `.zed/tasks.json`: - -```json -[ - { - "label": "Operator: Start API", - "command": "operator api", - "use_new_terminal": true - }, - { - "label": "Operator: Show Queue", - "command": "operator queue", - "use_new_terminal": false - }, - { - "label": "Operator: Launch Next", - "command": "operator launch --next", - "use_new_terminal": true - } -] + ├── MCP Context Server ──▶ operator mcp (stdio) + │ └── Tools + Resources available natively + │ + ├── ACP Agent Server ──▶ operator acp (stdio) + │ └── Prompts → delegator (Claude Code) → streaming output + │ + └── Slash Commands ──▶ Extension (WASM) + └── curl subprocess ──▶ Operator REST API (localhost:7008) ``` ## Configuration -The extension connects to `http://localhost:7008` by default. This matches Operator's default API port. - -To use a different API URL, you would need to modify the `DEFAULT_API_URL` constant in `src/lib.rs` and rebuild. +The MCP context server finds the `operator` binary on PATH, in `~/.cargo/bin/`, or at common install locations. The REST API URL for slash commands defaults to `http://localhost:7008`. ## Troubleshooting -### "Failed to execute curl" +### MCP tools not appearing -Ensure `curl` is available in your PATH. On most systems it's pre-installed. +1. Verify `operator` is on PATH: `which operator` +2. Test MCP server: `operator mcp` (should wait for JSON-RPC input) +3. Check Zed's extension logs: **View > Output > Extensions** -### "API request failed" +### Slash commands failing -1. Check that Operator is running: `operator api` -2. Verify the API is accessible: `curl http://localhost:7008/api/v1/health` -3. Check Operator logs for errors +1. Check that Operator API is running: `operator api` +2. Verify connectivity: `curl http://localhost:7008/api/v1/health` +3. Ensure `curl` is available ### Extension not appearing -1. Verify the extension files are in the correct location -2. Check Zed's extension logs: **View > Output > Extensions** -3. Try reloading extensions or restarting Zed +1. Verify files are in `~/.local/share/zed/extensions/installed/operator-dev/` +2. Reload extensions or restart Zed ## License diff --git a/zed-extension/TODO.md b/zed-extension/TODO.md index 60f4338..618f5df 100644 --- a/zed-extension/TODO.md +++ b/zed-extension/TODO.md @@ -1,139 +1,57 @@ -# Zed Extension TODO +# Zed Extension Status Feature comparison and implementation status vs VS Code extension. -## VS Code Extension Commands → Zed Slash Commands +## Integration Layers -| VS Code Command | Zed Equivalent | Status | -|-----------------|----------------|--------| -| `operator.showStatus` | `/op-status` | ✅ Implemented | -| `operator.refreshTickets` | N/A | ❌ No UI to refresh | -| `operator.focusTicket` | N/A | ❌ No terminal API | -| `operator.openTicket` | `/op-ticket` | ✅ Shows details (can't open file) | -| `operator.launchTicket` | `/op-launch` | ✅ Implemented | -| `operator.launchTicketWithOptions` | N/A | ❌ No dialog API | -| `operator.relaunchTicket` | `/op-launch` | ✅ Can relaunch same ticket | -| `operator.launchTicketFromEditor` | N/A | ❌ No editor context | -| `operator.downloadOperator` | N/A | ❌ Use manual install | -| `operator.pauseQueue` | `/op-pause` | ✅ Implemented | -| `operator.resumeQueue` | `/op-resume` | ✅ Implemented | -| `operator.syncKanban` | `/op-sync` | ✅ Implemented | -| `operator.approveReview` | `/op-approve` | ✅ Implemented | -| `operator.rejectReview` | `/op-reject` | ✅ Implemented | -| `operator.startOperatorServer` | N/A | ❌ Use Tasks or terminal | +| Layer | Status | Notes | +|-------|--------|-------| +| **MCP Context Server** | ✅ Implemented | `operator mcp` registered via `context_server_command()` | +| **ACP Agent Server** | ✅ Setup command | `/op-setup-agent` generates config for `~/.config/zed/settings.json` | +| **Slash Commands** | ✅ 14 commands | All original commands + `/op-setup`, `/op-help`, `/op-setup-agent` | +| **Guided Onboarding** | ✅ `/op-setup` | Health check checklist with next-step guidance | +| **Install Instructions** | ✅ Updated | Pre-built binary downloads from GitHub releases | ## VS Code Features → Zed Status | Feature | VS Code | Zed | Notes | |---------|---------|-----|-------| -| **Sidebar Views** | ✅ 4 TreeViews | ❌ N/A | Zed has no sidebar extension API | -| **Status Bar** | ✅ Live indicator | ❌ N/A | Zed has no status bar API | -| **Webhook Server** | ✅ Port 7009 | ❌ N/A | WASM sandbox prevents servers | -| **Terminal Management** | ✅ Create/style/track | ❌ N/A | Zed has no terminal extension API | -| **File Watching** | ✅ .tickets/ watcher | ❌ N/A | No file watcher in extensions | -| **REST Client** | ✅ Native fetch | ✅ curl subprocess | Works but slower | -| **Ticket Completion** | ✅ QuickPick | ✅ Argument completion | Works for ticket IDs | -| **Launch Options Dialog** | ✅ Multi-select | ❌ N/A | No dialog API | -| **Color-coded Terminals** | ✅ By issue type | ❌ N/A | Use Zed Tasks instead | - -## Implemented Slash Commands - -- [x] `/op-status` - Show Operator health/status -- [x] `/op-queue` - List tickets in queue -- [x] `/op-launch TICKET-ID` - Launch a ticket -- [x] `/op-active` - List active agents -- [x] `/op-completed` - List completed tickets -- [x] `/op-ticket TICKET-ID` - Show ticket details -- [x] `/op-pause` - Pause queue processing -- [x] `/op-resume` - Resume queue processing -- [x] `/op-sync` - Sync kanban collections -- [x] `/op-approve AGENT-ID` - Approve review -- [x] `/op-reject AGENT-ID REASON` - Reject review - -## Not Possible in Zed - -These features cannot be implemented due to Zed's extension API limitations: - -1. **Sidebar Views** - - No TreeDataProvider equivalent - - Status, queue, active, completed views all unavailable - - Workaround: Use slash commands to query data - -2. **Webhook Server** - - WASM sandbox prevents opening ports - - Cannot receive notifications from Operator - - Workaround: Poll with slash commands - -3. **Terminal Management** - - Cannot create/manage terminals programmatically - - Cannot set terminal colors or icons - - Workaround: Use Zed Tasks (`.zed/tasks.json`) - -4. **Status Bar** - - No API to add status bar items - - Cannot show persistent status indicator - - Workaround: Use `/op-status` command - -5. **File System Watching** - - Cannot watch for ticket file changes - - Auto-refresh not possible - - Workaround: Manual refresh via commands - -6. **Editor Context Commands** - - Cannot detect active editor file - - Cannot launch ticket from open file - - Workaround: Use `/op-launch` with explicit ID +| **MCP Tools** | ✅ Auto-connect | ✅ Context server | Native MCP via `operator mcp` stdio | +| **ACP Agent** | N/A | ✅ Via settings | Operator as agent in Agent Panel | +| **Slash Commands** | N/A | ✅ 12 commands | Thin inference layer for humans + AI | +| **Sidebar Views** | ✅ 4 TreeViews | ❌ N/A | MCP tools serve as alternative | +| **Status Bar** | ✅ Live indicator | ❌ N/A | Use `/op-status` or MCP health tool | +| **Webhook Server** | ✅ Port 7009 | ❌ N/A | MCP polling instead | +| **Terminal Management** | ✅ Create/style/track | ❌ N/A | ACP agent replaces terminal sessions | +| **File Watching** | ✅ .tickets/ watcher | ❌ N/A | Manual refresh via commands/tools | +| **Guided Onboarding** | ✅ 4-step walkthrough | ✅ `/op-setup` | Health check checklist with next steps | +| **Launch Options Dialog** | ✅ Multi-select | ❌ N/A | `/op-launch` with tab completion | +| **Color-coded Terminals** | ✅ By issue type | ❌ N/A | Use Zed Tasks as workaround | +| **Binary Download** | ✅ Command | ❌ PATH discovery | Extension finds binary on PATH | + +## Zed-Exclusive Capabilities + +Features Zed has that VS Code doesn't: + +1. **Native MCP integration** — tools appear directly in Agent Panel without manual config +2. **ACP agent sessions** — prompts flow through Operator to Claude Code delegator +3. **AI-accessible slash commands** — both humans and AI can use them in the assistant + +## Not Possible in Zed (API Limitations) + +1. **Sidebar views** — no TreeDataProvider equivalent +2. **Status bar items** — no extension API +3. **Terminal management** — no programmatic terminal API +4. **File watching** — no extension file watcher +5. **Agent server from WASM** — must use settings.json config (extension.toml requires binary downloads) +6. **Webhook server** — WASM sandbox prevents port listening ## Future Improvements When Zed's extension API expands: -- [ ] Add sidebar views if TreeDataProvider added -- [ ] Add status bar item if API becomes available -- [ ] Add terminal creation if API becomes available -- [ ] Add file watching for auto-refresh -- [ ] Add configuration UI when settings API available - -## Alternative Workflows - -### Using Zed Tasks - -For terminal-based workflows, create `.zed/tasks.json`: - -```json -[ - { - "label": "Operator: Start API Server", - "command": "operator api", - "use_new_terminal": true, - "allow_concurrent_runs": false - }, - { - "label": "Operator: Show Queue (CLI)", - "command": "operator queue", - "use_new_terminal": false - }, - { - "label": "Operator: Launch Next Ticket", - "command": "operator launch --next", - "use_new_terminal": true - }, - { - "label": "Operator: Show Active Agents", - "command": "operator agents", - "use_new_terminal": false - } -] -``` - -### Using the AI Assistant - -The slash commands are designed to work well in the AI assistant context: - -1. Ask about status: `/op-status` -2. See what needs work: `/op-queue` -3. Get details: `/op-ticket FIX-123` -4. Launch work: `/op-launch FIX-123` -5. Monitor progress: `/op-active` - -The AI assistant can use this information contextually to help with your work. +- [ ] Agent server registration from WASM (no manual settings.json) +- [ ] Sidebar views if TreeDataProvider added +- [ ] Status bar item if API becomes available +- [ ] File watching for auto-refresh +- [ ] Configuration UI when settings API available diff --git a/zed-extension/extension.toml b/zed-extension/extension.toml index 62a05ed..012b8ec 100644 --- a/zed-extension/extension.toml +++ b/zed-extension/extension.toml @@ -1,23 +1,25 @@ -[extension] id = "operator" name = "Operator" -description = "Integration with Operator multi-agent orchestration for Claude Code" -version = "0.1.0" +description = "Multi-agent orchestration for Coding Agents — MCP tools, ACP agent, and slash commands" +version = "0.2.0" schema_version = 1 authors = ["Samuel Volin "] repository = "https://github.com/untra/operator" -[language_servers] +[context_servers.operator] [slash_commands] -op-status = { description = "Show Operator health and status" } -op-queue = { description = "List tickets in queue" } +op-status = { description = "Show Operator health and status", requires_argument = false } +op-queue = { description = "List tickets in queue", requires_argument = false } op-launch = { description = "Launch a ticket by ID", requires_argument = true } -op-active = { description = "List active agents" } -op-completed = { description = "List recently completed tickets" } +op-active = { description = "List active agents", requires_argument = false } +op-completed = { description = "List recently completed tickets", requires_argument = false } op-ticket = { description = "Show ticket details by ID", requires_argument = true } -op-pause = { description = "Pause queue processing" } -op-resume = { description = "Resume queue processing" } -op-sync = { description = "Sync kanban collections" } +op-pause = { description = "Pause queue processing", requires_argument = false } +op-resume = { description = "Resume queue processing", requires_argument = false } +op-sync = { description = "Sync kanban collections", requires_argument = false } op-approve = { description = "Approve agent review by agent ID", requires_argument = true } op-reject = { description = "Reject agent review with reason", requires_argument = true } +op-setup-agent = { description = "Generate ACP agent server config for Zed settings", requires_argument = false } +op-setup = { description = "Check Operator installation and connection status", requires_argument = false } +op-help = { description = "List all available Operator commands", requires_argument = false } diff --git a/zed-extension/src/lib.rs b/zed-extension/src/lib.rs index 6427f96..e3c84b7 100644 --- a/zed-extension/src/lib.rs +++ b/zed-extension/src/lib.rs @@ -1,37 +1,64 @@ //! Zed Extension for Operator //! -//! Provides slash commands for interacting with the Operator multi-agent -//! orchestration system from Zed's AI assistant. -//! -//! Since Zed extensions run in a WASM sandbox, we communicate with the -//! Operator REST API via curl subprocess calls. +//! Provides three integration layers: +//! 1. MCP context server — registers `operator mcp` so Zed's agent panel +//! has native access to all operator tools and ticket resources. +//! 2. ACP agent setup — `/op-setup-agent` generates the config snippet +//! for Zed's `agent_servers` settings. +//! 3. Slash commands — thin AI/human inference layer for quick operations +//! with tab completion. use serde::Deserialize; -use std::process::Command; +use std::process::Command as StdCommand; use zed_extension_api::{ - self as zed, SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput, - SlashCommandOutputSection, + self as zed, Command, ContextServerConfiguration, ContextServerId, Project, SlashCommand, + SlashCommandArgumentCompletion, SlashCommandOutput, SlashCommandOutputSection, Worktree, }; -/// Default Operator API URL const DEFAULT_API_URL: &str = "http://localhost:7008"; -/// Operator Zed Extension +const KNOWN_BINARY_LOCATIONS: &[&str] = &["/usr/local/bin/operator", "/opt/homebrew/bin/operator"]; + struct OperatorExtension { api_url: String, + cached_binary_path: Option, } impl OperatorExtension { - fn new() -> Self { - Self { - api_url: DEFAULT_API_URL.to_string(), + fn find_operator_binary(&mut self, worktree: Option<&Worktree>) -> Option { + if let Some(ref path) = self.cached_binary_path { + return Some(path.clone()); + } + + if let Some(wt) = worktree { + if let Some(path) = wt.which("operator") { + self.cached_binary_path = Some(path.clone()); + return Some(path); + } + } + + for location in KNOWN_BINARY_LOCATIONS { + if std::fs::metadata(location).is_ok() { + self.cached_binary_path = Some((*location).to_string()); + return Some((*location).to_string()); + } + } + + // Try home directory cargo bin + if let Ok(home) = std::env::var("HOME") { + let cargo_bin = format!("{home}/.cargo/bin/operator"); + if std::fs::metadata(&cargo_bin).is_ok() { + self.cached_binary_path = Some(cargo_bin.clone()); + return Some(cargo_bin); + } } + + None } - /// Execute a curl command and return the output fn curl_get(&self, endpoint: &str) -> Result { let url = format!("{}{}", self.api_url, endpoint); - let output = Command::new("curl") + let output = StdCommand::new("curl") .args(["-s", "-f", &url]) .output() .map_err(|e| format!("Failed to execute curl: {}", e))?; @@ -44,10 +71,9 @@ impl OperatorExtension { } } - /// Execute a curl POST command fn curl_post(&self, endpoint: &str, body: Option<&str>) -> Result { let url = format!("{}{}", self.api_url, endpoint); - let mut cmd = Command::new("curl"); + let mut cmd = StdCommand::new("curl"); cmd.args(["-s", "-f", "-X", "POST"]); if let Some(json_body) = body { @@ -68,7 +94,6 @@ impl OperatorExtension { } } - /// Handle /op-status command fn handle_status(&self) -> SlashCommandOutput { match self.curl_get("/api/v1/health") { Ok(json) => { @@ -104,13 +129,13 @@ impl OperatorExtension { Err(e) => make_error(&format!( "Failed to get Operator status.\n\n\ **Error**: {}\n\n\ - Make sure Operator is running: `operator api`", + Make sure Operator is running: `operator api`\n\n\ + Run `/op-setup` to diagnose your installation.", e )), } } - /// Handle /op-queue command fn handle_queue(&self) -> SlashCommandOutput { match self.curl_get("/api/v1/tickets/queue") { Ok(json) => { @@ -140,11 +165,13 @@ impl OperatorExtension { make_output(&format!("```json\n{}\n```", json), "Queue (raw)") } } - Err(e) => make_error(&format!("Failed to fetch queue: {}", e)), + Err(e) => make_error(&format!( + "Failed to fetch queue: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + e + )), } } - /// Handle /op-launch command fn handle_launch(&self, ticket_id: &str) -> SlashCommandOutput { let body = r#"{"provider":null,"wrapper":"terminal","model":"sonnet","yolo_mode":false,"retry_reason":null,"resume_session_id":null}"#; match self.curl_post(&format!("/api/v1/tickets/{}/launch", ticket_id), Some(body)) { @@ -172,11 +199,13 @@ impl OperatorExtension { make_output(&format!("```json\n{}\n```", json), "Launch Response") } } - Err(e) => make_error(&format!("Failed to launch ticket {}: {}", ticket_id, e)), + Err(e) => make_error(&format!( + "Failed to launch ticket {}: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + ticket_id, e + )), } } - /// Handle /op-active command fn handle_active(&self) -> SlashCommandOutput { match self.curl_get("/api/v1/agents/active") { Ok(json) => { @@ -203,11 +232,13 @@ impl OperatorExtension { make_output(&format!("```json\n{}\n```", json), "Active Agents (raw)") } } - Err(e) => make_error(&format!("Failed to fetch active agents: {}", e)), + Err(e) => make_error(&format!( + "Failed to fetch active agents: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + e + )), } } - /// Handle /op-completed command fn handle_completed(&self) -> SlashCommandOutput { match self.curl_get("/api/v1/tickets/completed") { Ok(json) => { @@ -241,11 +272,13 @@ impl OperatorExtension { make_output(&format!("```json\n{}\n```", json), "Completed (raw)") } } - Err(e) => make_error(&format!("Failed to fetch completed tickets: {}", e)), + Err(e) => make_error(&format!( + "Failed to fetch completed tickets: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + e + )), } } - /// Handle /op-ticket command fn handle_ticket(&self, ticket_id: &str) -> SlashCommandOutput { match self.curl_get(&format!("/api/v1/tickets/{}", ticket_id)) { Ok(json) => { @@ -271,11 +304,13 @@ impl OperatorExtension { make_output(&format!("```json\n{}\n```", json), "Ticket (raw)") } } - Err(e) => make_error(&format!("Failed to fetch ticket {}: {}", ticket_id, e)), + Err(e) => make_error(&format!( + "Failed to fetch ticket {}: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + ticket_id, e + )), } } - /// Handle /op-pause command fn handle_pause(&self) -> SlashCommandOutput { match self.curl_post("/api/v1/queue/pause", None) { Ok(json) => { @@ -291,11 +326,13 @@ impl OperatorExtension { ) } } - Err(e) => make_error(&format!("Failed to pause queue: {}", e)), + Err(e) => make_error(&format!( + "Failed to pause queue: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + e + )), } } - /// Handle /op-resume command fn handle_resume(&self) -> SlashCommandOutput { match self.curl_post("/api/v1/queue/resume", None) { Ok(json) => { @@ -311,11 +348,13 @@ impl OperatorExtension { ) } } - Err(e) => make_error(&format!("Failed to resume queue: {}", e)), + Err(e) => make_error(&format!( + "Failed to resume queue: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + e + )), } } - /// Handle /op-sync command fn handle_sync(&self) -> SlashCommandOutput { match self.curl_post("/api/v1/kanban/sync", None) { Ok(json) => { @@ -334,11 +373,13 @@ impl OperatorExtension { make_output(&format!("```json\n{}\n```", json), "Kanban Sync (raw)") } } - Err(e) => make_error(&format!("Failed to sync kanban: {}", e)), + Err(e) => make_error(&format!( + "Failed to sync kanban: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + e + )), } } - /// Handle /op-approve command fn handle_approve(&self, agent_id: &str) -> SlashCommandOutput { match self.curl_post(&format!("/api/v1/agents/{}/approve", agent_id), None) { Ok(json) => { @@ -354,13 +395,14 @@ impl OperatorExtension { ) } } - Err(e) => make_error(&format!("Failed to approve agent {}: {}", agent_id, e)), + Err(e) => make_error(&format!( + "Failed to approve agent {}: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + agent_id, e + )), } } - /// Handle /op-reject command fn handle_reject(&self, args: &str) -> SlashCommandOutput { - // Parse: AGENT-ID REASON let parts: Vec<&str> = args.splitn(2, ' ').collect(); if parts.len() < 2 { return make_error( @@ -389,11 +431,158 @@ impl OperatorExtension { ) } } - Err(e) => make_error(&format!("Failed to reject agent {}: {}", agent_id, e)), + Err(e) => make_error(&format!( + "Failed to reject agent {}: {}\n\nIs the Operator server running? Try `operator api` or run `/op-setup`.", + agent_id, e + )), + } + } + + fn handle_setup(&self, worktree: Option<&Worktree>) -> SlashCommandOutput { + let mut lines = Vec::new(); + let mut next_steps = Vec::new(); + + lines.push("## Operator Setup Status\n".to_string()); + + // 1. Extension installed (always true if we're running) + lines.push("- [x] **Extension installed** — Operator Zed extension v0.2.0".to_string()); + + // 2. Binary found? + match find_operator_binary_oneshot(worktree) { + Some(path) => { + lines.push(format!("- [x] **Binary found** — `{}`", path)); + } + None => { + lines.push("- [ ] **Binary not found** — `operator` is not on PATH".to_string()); + next_steps.push( + "Install operator: download from https://github.com/untra/operator/releases/latest" + .to_string(), + ); + } + } + + // 3. API reachable? + match self.curl_get("/api/v1/health") { + Ok(json) => { + if let Ok(health) = serde_json::from_str::(&json) { + lines.push(format!( + "- [x] **API server running** — v{}, {} queued, {} active", + health.version, health.queue_count, health.active_agents + )); + } else { + lines.push("- [x] **API server running** — connected".to_string()); + } + } + Err(_) => { + lines.push(format!( + "- [ ] **API server not running** — could not reach `{}`", + self.api_url + )); + next_steps.push("Start the Operator server: `operator api`".to_string()); + } } + + // 4. MCP context server (always active if extension is loaded) + lines.push( + "- [x] **MCP context server** — active (tools available in Agent Panel)".to_string(), + ); + + if !next_steps.is_empty() { + lines.push("\n### Next Steps\n".to_string()); + for (i, step) in next_steps.iter().enumerate() { + lines.push(format!("{}. {}", i + 1, step)); + } + } else { + lines.push( + "\nAll prerequisites met. Use `/op-help` to see available commands.".to_string(), + ); + } + + // ACP is optional — show as a tip, not a checkbox + lines.push( + "\n> **Tip — ACP agent (optional):** Run `/op-setup-agent` to enable AI-to-Operator prompt delegation via `~/.config/zed/settings.json`." + .to_string(), + ); + + let text = lines.join("\n"); + make_output(&text, "Operator Setup") + } + + fn handle_help() -> SlashCommandOutput { + let text = "\ +## Operator Commands + +| Command | Description | +|---------|-------------| +| `/op-setup` | Check installation and connection status | +| `/op-status` | Show Operator health and queue metrics | +| `/op-queue` | List tickets waiting in queue | +| `/op-launch TICKET-ID` | Launch a ticket (tab-complete available) | +| `/op-active` | List active agents | +| `/op-completed` | List recently completed tickets | +| `/op-ticket TICKET-ID` | Show full ticket details | +| `/op-pause` | Pause queue processing | +| `/op-resume` | Resume queue processing | +| `/op-sync` | Sync kanban collections | +| `/op-approve AGENT-ID` | Approve an agent's review (tab-complete available) | +| `/op-reject AGENT-ID REASON` | Reject an agent's review with reason | +| `/op-setup-agent` | Generate ACP agent server config for Zed settings | +| `/op-help` | Show this command reference | + +### Getting Started + +1. Run `/op-setup` to verify your installation +2. Run `/op-setup-agent` to enable AI-to-Operator prompt delegation +3. Use `/op-status` to check the system is healthy +4. Use `/op-queue` to see available work + +Documentation: https://operator.untra.io"; + + make_output(text, "Operator Help") + } + + fn handle_setup_agent(&self, worktree: Option<&Worktree>) -> SlashCommandOutput { + let binary_path = match find_operator_binary_oneshot(worktree) { + Some(path) => path, + None => { + return make_error( + "Could not find `operator` binary.\n\n\ + Install operator first, then re-run this command.\n\n\ + Download from: https://github.com/untra/operator/releases/latest\n\n\ + Or build from source: `cargo install --git https://github.com/untra/operator`\n\n\ + Run `/op-setup` to check your installation status.", + ); + } + }; + + let snippet = format!( + r#"{{ + "agent_servers": {{ + "operator": {{ + "command": "{}", + "args": ["acp"], + "env": {{}} + }} + }} +}}"#, + binary_path.replace('\\', "\\\\").replace('"', "\\\"") + ); + + let text = format!( + "## ACP Agent Server Setup\n\n\ + Add the following to your Zed settings (`~/.config/zed/settings.json`):\n\n\ + ```json\n{}\n```\n\n\ + **Binary**: `{}`\n\n\ + After adding this config, restart Zed. Operator will appear as an agent \ + in the Agent Panel. You can send prompts to it and it will delegate to \ + Claude Code via the ACP protocol.\n\n\ + This is a one-time setup step.", + snippet, binary_path + ); + + make_output(&text, "ACP Agent Setup") } - /// Get ticket IDs for completion fn get_queue_ticket_ids(&self) -> Vec { if let Ok(json) = self.curl_get("/api/v1/tickets/queue") { if let Ok(response) = serde_json::from_str::(&json) { @@ -403,7 +592,6 @@ impl OperatorExtension { Vec::new() } - /// Get agent IDs awaiting input for completion fn get_awaiting_agent_ids(&self) -> Vec<(String, String)> { if let Ok(json) = self.curl_get("/api/v1/agents/active") { if let Ok(response) = serde_json::from_str::(&json) { @@ -424,14 +612,100 @@ impl zed::Extension for OperatorExtension { where Self: Sized, { - OperatorExtension::new() + OperatorExtension { + api_url: DEFAULT_API_URL.to_string(), + cached_binary_path: None, + } + } + + fn context_server_command( + &mut self, + _context_server_id: &ContextServerId, + _project: &Project, + ) -> Result { + // Try known locations first; fall back to bare "operator" so Zed's + // process spawn does its own PATH resolution. + let binary_path = self + .find_operator_binary(None) + .unwrap_or_else(|| "operator".to_string()); + + Ok(Command { + command: binary_path, + args: vec!["mcp".to_string()], + env: vec![], + }) + } + + fn context_server_configuration( + &mut self, + _context_server_id: &ContextServerId, + _project: &Project, + ) -> Result, String> { + Ok(Some(ContextServerConfiguration { + installation_instructions: "\ +## Install Operator + +Operator provides multi-agent orchestration for Claude Code and other LLM coding agents. + +### Download (recommended) + +Download the latest binary for your platform: +https://github.com/untra/operator/releases/latest + +**macOS (Apple Silicon):** +```bash +curl -L https://github.com/untra/operator/releases/latest/download/operator-macos-arm64 -o /usr/local/bin/operator && chmod +x /usr/local/bin/operator +``` + +**Linux (x86_64):** +```bash +curl -L https://github.com/untra/operator/releases/latest/download/operator-linux-x86_64 -o /usr/local/bin/operator && chmod +x /usr/local/bin/operator +``` + +**Linux (arm64):** +```bash +curl -L https://github.com/untra/operator/releases/latest/download/operator-linux-arm64 -o /usr/local/bin/operator && chmod +x /usr/local/bin/operator +``` + +### From source (Rust) +```bash +cargo install --git https://github.com/untra/operator +``` + +### After installing + +```bash +operator --version # verify the binary +operator api # start the REST API server on localhost:7008 +``` + +Run `/op-setup` in the assistant to check your connection status. + +Documentation: https://operator.untra.io" + .to_string(), + settings_schema: serde_json::json!({ + "type": "object", + "properties": { + "api_url": { + "type": "string", + "default": "http://localhost:7008", + "description": "Operator REST API URL (used by slash commands)" + } + } + }) + .to_string(), + default_settings: serde_json::json!({ + "api_url": "http://localhost:7008" + }) + .to_string(), + })) } fn run_slash_command( &self, command: SlashCommand, args: Vec, - _worktree: Option<&zed::Worktree>, + worktree: Option<&Worktree>, ) -> Result { let arg = args.join(" "); @@ -471,6 +745,9 @@ impl zed::Extension for OperatorExtension { Ok(self.handle_reject(&arg)) } } + "op-setup-agent" => Ok(self.handle_setup_agent(worktree)), + "op-setup" => Ok(self.handle_setup(worktree)), + "op-help" => Ok(Self::handle_help()), _ => Err(format!("Unknown command: {}", command.name)), } } @@ -509,8 +786,8 @@ impl zed::Extension for OperatorExtension { .into_iter() .map(|(id, ticket_id)| SlashCommandArgumentCompletion { label: format!("{} ({})", &id[..8.min(id.len())], ticket_id), - new_text: format!("{} ", id), // Space for reason - run_command: false, // Don't run yet, need reason + new_text: format!("{} ", id), + run_command: false, }) .collect()) } @@ -519,7 +796,29 @@ impl zed::Extension for OperatorExtension { } } -// Helper function to create output +fn find_operator_binary_oneshot(worktree: Option<&Worktree>) -> Option { + if let Some(wt) = worktree { + if let Some(path) = wt.which("operator") { + return Some(path); + } + } + + for location in KNOWN_BINARY_LOCATIONS { + if std::fs::metadata(location).is_ok() { + return Some((*location).to_string()); + } + } + + if let Ok(home) = std::env::var("HOME") { + let cargo_bin = format!("{home}/.cargo/bin/operator"); + if std::fs::metadata(&cargo_bin).is_ok() { + return Some(cargo_bin); + } + } + + None +} + fn make_output(text: &str, label: &str) -> SlashCommandOutput { SlashCommandOutput { text: text.to_string(), @@ -530,7 +829,6 @@ fn make_output(text: &str, label: &str) -> SlashCommandOutput { } } -// Helper function to create error output fn make_error(message: &str) -> SlashCommandOutput { let text = format!("## Error\n\n{}", message); SlashCommandOutput { @@ -542,7 +840,6 @@ fn make_error(message: &str) -> SlashCommandOutput { } } -// API Response types #[derive(Deserialize)] struct HealthResponse { status: String,