diff --git a/.archgate/adrs/ARCH-006-dependency-policy.md b/.archgate/adrs/ARCH-006-dependency-policy.md index 3a86e669..4b0b6db0 100644 --- a/.archgate/adrs/ARCH-006-dependency-policy.md +++ b/.archgate/adrs/ARCH-006-dependency-policy.md @@ -10,7 +10,9 @@ files: ["package.json"] Minimizing dependencies reduces supply chain risk, install size, and maintenance burden. Every production dependency is a trust relationship: the project trusts the dependency's maintainers, their CI/CD pipeline, and every transitive dependency in the tree. Supply chain attacks targeting popular npm packages (event-stream, ua-parser-js, colors.js) have demonstrated that this trust is frequently exploited. -Bun provides many built-in capabilities that eliminate the need for external packages — file I/O (`Bun.file`, `Bun.write`), HTTP server, shell commands (`Bun.$`), glob (`Bun.Glob`), TOML/YAML parsing, and testing. The fewer external packages in the dependency tree, the smaller the attack surface and the faster the install. +Bun provides many built-in capabilities that eliminate the need for external packages — file I/O (`Bun.file`, `Bun.write`), HTTP server, subprocess execution (`Bun.spawn`), glob (`Bun.Glob`), TOML/YAML parsing, and testing. The fewer external packages in the dependency tree, the smaller the attack surface and the faster the install. + +> **Note on `Bun.$` (Bun shell):** The `Bun.$` template literal API hangs on Windows because the shell subprocess does not properly close stdin/stdout pipes, causing deadlocks that freeze the process. This project uses `Bun.spawn` (array-based, no shell) exclusively for cross-platform subprocess execution. See commit `ca33377` for the migration. **Alternatives considered:** @@ -43,7 +45,9 @@ Development dependencies (`devDependencies`) are less restricted but should stil ### Do -- Use Bun built-ins for file I/O (`Bun.file`, `Bun.write`), HTTP, shell commands (`Bun.$`), glob (`Bun.Glob`), testing (`bun:test`) +- Use Bun built-ins for file I/O (`Bun.file`, `Bun.write`), HTTP, subprocess execution (`Bun.spawn`), glob (`Bun.Glob`), testing (`bun:test`) +- Use `Bun.spawn` with array-based arguments for all subprocess execution — it works correctly on macOS, Linux, and Windows +- **DON'T** use `Bun.$` (Bun shell template literals) for subprocess execution — it hangs on Windows due to pipe handling issues - Justify any new production dependency in a PR description - Keep `devDependencies` for tooling only (linting, formatting, commitlint) - Review the transitive dependency tree before adding a package @@ -70,8 +74,10 @@ await Bun.write("output.json", JSON.stringify(data)); const glob = new Bun.Glob("src/**/*.ts"); const files = Array.from(glob.scanSync({ cwd: projectRoot })); -// Shell commands — use Bun built-in -const result = await Bun.$`git ls-files`.text(); +// Subprocess execution — use Bun.spawn (cross-platform, no shell) +const proc = Bun.spawn(["git", "ls-files"], { stdout: "pipe", stderr: "pipe" }); +const result = await new Response(proc.stdout).text(); +await proc.exited; // Colors — use node:util built-in (not chalk) import { styleText } from "node:util"; @@ -109,12 +115,12 @@ const subset = pick(obj, ["a", "b"]); ### Negative -- **Bun built-in documentation is less comprehensive** — Some Bun APIs (`Bun.Glob`, `Bun.$`) have less documentation and fewer community examples than their npm counterparts (`glob`, `execa`). Contributors may need to reference Bun's source or test files. +- **Bun built-in documentation is less comprehensive** — Some Bun APIs (`Bun.Glob`, `Bun.spawn`) have less documentation and fewer community examples than their npm counterparts (`glob`, `execa`). Contributors may need to reference Bun's source or test files. - **Bun API surface may change** — Bun is actively developing and some APIs may change between minor versions. Pinning via `.prototools` mitigates but does not eliminate this risk. ### Risks -- **Bun API instability** — Bun built-in APIs (especially newer ones like `Bun.Glob`, `Bun.$`) may introduce breaking changes or behavioral differences between versions. +- **Bun API instability** — Bun built-in APIs (especially newer ones like `Bun.Glob`, `Bun.spawn`) may introduce breaking changes or behavioral differences between versions. The `Bun.$` shell API is explicitly avoided due to Windows pipe deadlocks discovered in production. - **Mitigation:** The project pins Bun version via `.prototools` (currently 1.3.8). API changes are caught during controlled upgrades with full test suite validation. - **Bun built-in feature gaps** — Some functionality may be missing from Bun built-ins (e.g., advanced glob options, streaming HTTP edge cases). If a Bun built-in lacks a critical feature, the fallback is to add an approved dependency with full justification. - **Mitigation:** The approved dependency list exists precisely for this case. The threshold is "Bun cannot do this," not "Bun can do this but an npm package is slightly more convenient." diff --git a/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.md b/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.md new file mode 100644 index 00000000..5286457a --- /dev/null +++ b/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.md @@ -0,0 +1,185 @@ +--- +id: ARCH-007 +title: Cross-Platform Subprocess Execution +domain: architecture +rules: true +files: + - "src/**/*.ts" +--- + +## Context + +The Archgate CLI runs on macOS, Linux, and Windows. Several operations require spawning subprocesses: git commands (`git ls-files`, `git diff`), editor CLI calls (`claude plugin install`, `copilot plugin install`), archive extraction (`tar -xzf`), and package management (`npm install -g`). These subprocesses must work identically on all three platforms. + +Bun provides two subprocess APIs: + +- **`Bun.$` (shell template literals)** — A shell-like API that pipes commands through a subprocess shell. Convenient syntax (`await Bun.$\`git ls-files\`.text()`), but relies on platform-specific shell behavior. +- **`Bun.spawn` (array-based)** — A lower-level API that executes a command directly (no intermediate shell). Takes an array of arguments, explicit pipe configuration, and returns a process handle with `stdout`, `stderr`, and `exited` properties. + +**The problem:** `Bun.$` hangs on Windows. The shell subprocess does not properly close stdin/stdout pipes, causing deadlocks that block the calling thread indefinitely. When the Archgate CLI runs as an MCP server inside Claude Code or Cursor, this deadlock freezes the entire editor's agent interface — the user must force-kill the process. This was discovered in production and fixed in commit `ca33377`, which replaced all `Bun.$` calls with `Bun.spawn`. + +**Alternatives considered:** + +- **`Bun.$` with `.nothrow().quiet()`** — Adding error handling modifiers does not resolve the pipe deadlock. The hang occurs at the pipe level before any Bun-level error handling takes effect. +- **`node:child_process` (`execFile`, `spawn`)** — Node.js subprocess APIs work cross-platform but are callback-based or require manual stream wiring. `Bun.spawn` provides the same array-based execution model with native Promise/async support and direct `Bun.file`-compatible stdout. +- **Third-party libraries (`execa`, `cross-spawn`)** — These add production dependencies that [ARCH-006](./ARCH-006-dependency-policy.md) explicitly prohibits when Bun built-ins suffice. `Bun.spawn` covers all use cases without external packages. + +For Archgate, every subprocess call is either a git command, an editor CLI invocation, or a system tool (`tar`, `npm`). All of these are simple array-based command executions that do not require shell features (pipes, globbing, redirection). `Bun.spawn` is the correct tool. + +## Decision + +All subprocess execution in the Archgate CLI MUST use `Bun.spawn` with array-based arguments. The `Bun.$` shell template literal API is **forbidden** in all source files. + +This decision covers: + +- Git operations (`git ls-files`, `git diff`, `git status`) +- Editor CLI calls (`claude plugin marketplace add`, `copilot plugin install`) +- System tool invocations (`tar`, `npm`) +- Any other subprocess execution added in the future + +This decision does NOT cover: + +- Test files — test helpers may use `Bun.$` if tests only run on a single platform (though `Bun.spawn` is still preferred) +- Build scripts — scripts that explicitly target a single platform are exempt + +`Bun.spawn` will be used alongside: + +- [ARCH-006 — Dependency Policy](./ARCH-006-dependency-policy.md) — `Bun.spawn` is a Bun built-in, no external dependency needed +- [ARCH-002 — Error Handling](./ARCH-002-error-handling.md) — Subprocess failures MUST be handled with proper error messages and exit codes + +## Do's and Don'ts + +### Do + +- **DO** use `Bun.spawn(["command", "arg1", "arg2"], { stdout: "pipe", stderr: "pipe" })` for commands whose output you need to capture +- **DO** read stdout via `new Response(proc.stdout).text()` — this is the idiomatic Bun pattern for consuming a `ReadableStream` +- **DO** always `await proc.exited` after reading stdout to ensure the process has terminated +- **DO** use `stdout: "inherit"` and `stderr: "inherit"` for commands whose output should go directly to the terminal (e.g., `npm install -g`) +- **DO** wrap CLI availability checks in `try/catch` returning a boolean — the command may not exist on the system +- **DO** pass `cwd` via the options object when the command must run in a specific directory +- **DO** extract a helper function (e.g., `run(cmd, opts)` or `runGit(args, cwd)`) when multiple subprocess calls share the same pattern within a module + +### Don't + +- **DON'T** use `Bun.$` template literals (`Bun.$\`command\``) — they hang on Windows due to pipe deadlocks +- **DON'T** import `$` from `"bun"` — this is the Bun shell API that causes Windows deadlocks +- **DON'T** use shell features (pipes `|`, redirects `>`, globbing `*`) in subprocess arguments — `Bun.spawn` executes commands directly without a shell +- **DON'T** forget to `await proc.exited` — reading stdout alone does not guarantee the process has terminated +- **DON'T** use `node:child_process` when `Bun.spawn` provides the same capability — prefer Bun built-ins per [ARCH-006](./ARCH-006-dependency-policy.md) + +## Implementation Pattern + +### Good Example + +```typescript +// Capture command output (git, tar, etc.) +async function run( + cmd: string[], + opts?: { cwd?: string } +): Promise<{ exitCode: number; stdout: string }> { + const proc = Bun.spawn(cmd, { + cwd: opts?.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return { exitCode, stdout }; +} + +// Usage +const { exitCode, stdout } = await run( + ["git", "diff", "--cached", "--name-only"], + { cwd: projectRoot } +); +const files = stdout.trim().split("\n").filter(Boolean); +``` + +```typescript +// CLI availability check +async function isClaudeCliAvailable(): Promise { + try { + const { exitCode } = await run(["claude", "--version"]); + return exitCode === 0; + } catch { + return false; // Command not found on PATH + } +} +``` + +```typescript +// Inherit output for interactive/visible commands +const proc = Bun.spawn(["npm", "install", "-g", "archgate@latest"], { + stdout: "inherit", + stderr: "inherit", +}); +const exitCode = await proc.exited; +``` + +### Bad Example + +```typescript +// BAD: Bun.$ hangs on Windows — pipe deadlock +import { $ } from "bun"; +const result = await $`git ls-files`.text(); + +// BAD: .nothrow().quiet() does not fix the pipe issue +const result = await $`git diff --cached --name-only`.nothrow().quiet().text(); + +// BAD: Shell features don't work with Bun.spawn +Bun.spawn(["git diff --cached | head -5"]); // This is a single argument, not a pipeline +``` + +## Consequences + +### Positive + +- **Cross-platform reliability** — `Bun.spawn` works identically on macOS, Linux, and Windows. No platform-specific pipe handling differences. +- **No deadlocks** — Array-based execution avoids the stdin/stdout pipe issues that cause `Bun.$` to hang on Windows. +- **MCP server safety** — The CLI runs as a long-lived MCP server inside editors. A subprocess deadlock would freeze the entire agent interface. `Bun.spawn` eliminates this risk. +- **Explicit argument handling** — Array-based arguments prevent shell injection vulnerabilities. Each argument is passed directly to the command, not interpreted by a shell. +- **No shell dependency** — The command does not require a shell interpreter (bash, cmd.exe, PowerShell) to be available or configured correctly. +- **Consistent error handling** — `proc.exited` returns a Promise that resolves to the exit code, making error checking uniform across all subprocess calls. + +### Negative + +- **More verbose syntax** — `Bun.spawn(["git", "ls-files"], { stdout: "pipe" })` is more verbose than `Bun.$\`git ls-files\``. The convenience of template literals is lost. + - This is mitigated by extracting `run()` or `runGit()` helper functions within each module. +- **No shell features** — Pipelines (`cmd1 | cmd2`), redirects (`> file`), and glob expansion (`*.ts`) are not available. Each must be implemented in JavaScript. + - The Archgate CLI does not use any of these shell features. All subprocess calls are simple command executions. +- **Manual stream consumption** — Reading stdout requires `new Response(proc.stdout).text()` instead of the simpler `.text()` chain on `Bun.$`. + +### Risks + +- **Future Bun.$ fix** — Bun may fix the Windows pipe issue in a future version, making `Bun.$` safe again. At that point, the project could relax this restriction. + - **Mitigation:** The ADR stands until verified on all three platforms with the fixed Bun version. A relaxation requires updating this ADR with the minimum safe Bun version. +- **Complex subprocess needs** — A future feature may require shell features (pipelines, redirects) that `Bun.spawn` cannot provide. + - **Mitigation:** Implement the pipeline logic in JavaScript (spawn multiple processes, pipe streams manually). If this becomes frequent, evaluate adding a subprocess helper library as an approved dependency under [ARCH-006](./ARCH-006-dependency-policy.md). + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** `ARCH-007/no-bun-shell`: Scans all TypeScript source files for `Bun.$` usage and `$` imports from `"bun"`. Severity: `error` (hard blocker). + +### Manual Enforcement + +Code reviewers MUST verify: + +1. No `Bun.$` template literals appear in new or modified code +2. No `import { $ } from "bun"` or `import { $, ... } from "bun"` statements exist +3. All subprocess calls use `Bun.spawn` with array-based arguments +4. `proc.exited` is awaited after reading stdout/stderr +5. CLI availability checks are wrapped in `try/catch` + +### Exceptions + +Test files (`tests/**/*.ts`) MAY use `Bun.$` if the test targets a single platform. However, `Bun.spawn` is still preferred for consistency. Any exception must be documented with a comment explaining why `Bun.$` is acceptable in that specific case. + +## References + +- [ARCH-006 — Dependency Policy](./ARCH-006-dependency-policy.md) — Mandates Bun built-ins over external packages; updated to remove `Bun.$` recommendation +- [ARCH-002 — Error Handling](./ARCH-002-error-handling.md) — Defines error handling standards for subprocess failures +- [Bun.spawn documentation](https://bun.sh/docs/api/spawn) +- [Bun.$ documentation](https://bun.sh/docs/runtime/shell) — Documents the shell API that this ADR prohibits +- Commit `ca33377` — The production fix that migrated all `Bun.$` calls to `Bun.spawn` diff --git a/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts b/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts new file mode 100644 index 00000000..b1dbbaf0 --- /dev/null +++ b/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts @@ -0,0 +1,63 @@ +import { defineRules } from "../../src/formats/rules"; + +export default defineRules({ + "no-bun-shell": { + description: + "Subprocess execution must use Bun.spawn, not Bun.$ (shell hangs on Windows)", + async check(ctx) { + const files = ctx.scopedFiles.filter( + (f) => !f.includes("tests/") && !f.includes(".archgate/") + ); + + // Check for Bun.$ template literal usage + const bunShellMatches = await Promise.all( + files.map((file) => ctx.grep(file, /Bun\.\$`/)) + ); + for (const fileMatches of bunShellMatches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + "Do not use Bun.$ template literals — they hang on Windows due to pipe deadlocks. Use Bun.spawn instead.", + file: m.file, + line: m.line, + fix: "Replace Bun.$`cmd args` with Bun.spawn(['cmd', 'args'], { stdout: 'pipe', stderr: 'pipe' })", + }); + } + } + + // Check for $ import from "bun" (the shell API) + const dollarImportMatches = await Promise.all( + files.map((file) => + ctx.grep(file, /import\s*\{[^}]*\$[^}]*\}\s*from\s*["']bun["']/) + ) + ); + for (const fileMatches of dollarImportMatches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + 'Do not import $ from "bun" — the Bun shell API hangs on Windows. Use Bun.spawn instead.', + file: m.file, + line: m.line, + fix: 'Remove the $ import from "bun" and replace shell calls with Bun.spawn', + }); + } + } + + // Check for await $` pattern (destructured $ usage) + const destructuredMatches = await Promise.all( + files.map((file) => ctx.grep(file, /await\s+\$`/)) + ); + for (const fileMatches of destructuredMatches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + "Do not use $` template literals (destructured Bun shell) — they hang on Windows. Use Bun.spawn instead.", + file: m.file, + line: m.line, + fix: "Replace $`cmd args` with Bun.spawn(['cmd', 'args'], { stdout: 'pipe', stderr: 'pipe' })", + }); + } + } + }, + }, +}); diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..55daa16c --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "archgate": { + "command": "archgate", + "args": ["mcp"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 0f57a031..25abac1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,9 @@ Bun 1.3.8, Moon 1.39.4, Node LTS, npm 11.6.0. Minimum user-facing Bun: `>=1.2.21 - `ARCH-004` — No barrel files (direct imports only) - `ARCH-005` — Testing standards (Bun test, fixtures, 80% coverage) - `ARCH-006` — Dependency policy (minimal deps, Bun built-ins) +- `ARCH-007` — Cross-platform subprocess execution (Bun.spawn, no Bun.$) +- `GEN-001` — Documentation site (Astro Starlight) +- `GEN-002` — Documentation internationalization (en + pt-br parity) ## ADR Format diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 348edcf7..67f2f9f3 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -121,6 +121,14 @@ export default defineConfig({ label: "Claude Code Plugin", slug: "guides/claude-code-plugin", }, + { + label: "VS Code Plugin", + slug: "guides/vscode-plugin", + }, + { + label: "Copilot CLI Plugin", + slug: "guides/copilot-cli-plugin", + }, { label: "Cursor Integration", slug: "guides/cursor-integration", diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx new file mode 100644 index 00000000..70599561 --- /dev/null +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -0,0 +1,147 @@ +--- +title: Copilot CLI Plugin +description: Install the Archgate plugin for GitHub Copilot CLI with AI governance. +--- + +The Archgate Copilot CLI plugin gives AI agents working in [GitHub Copilot CLI](https://github.com/features/copilot) a structured governance workflow. Agents read your ADRs before writing code, validate after, and capture new patterns for the team -- the same workflow available in the [Claude Code plugin](/guides/claude-code-plugin/). + +## How it works + +Copilot CLI supports plugin installation from git repositories using `copilot plugin install`. The Archgate plugin is served from a git repository at `plugins.archgate.dev/archgate.git`, which Copilot CLI recognizes natively -- the same `.claude-plugin/plugin.json` manifest format works for both Claude Code and Copilot CLI. + +## Installation + +:::note[Beta access required] +The Copilot CLI plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +::: + +### 1. Log in with GitHub + +Authenticate with your GitHub account to obtain a plugin token: + +```bash +archgate login +``` + +This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored in `~/.archgate/credentials`. + +### 2. Initialize your project with the plugin + +Run `archgate init` with the `--editor copilot` flag: + +```bash +archgate init --editor copilot +``` + +If you are already logged in and the `copilot` CLI is on your PATH, the plugin is installed automatically via: + +```bash +copilot plugin install https://:@plugins.archgate.dev/archgate.git +``` + +If the `copilot` CLI is not found, the command prints the manual command for you to run. + +To explicitly request plugin installation: + +```bash +archgate init --editor copilot --install-plugin +``` + +### Generated files + +The command creates the following configuration: + +| File | Purpose | +| -------------------------- | ---------------------------------------------------- | +| `.github/copilot/mcp.json` | MCP server connection so Copilot CLI can access ADRs | + +If `.github/copilot/mcp.json` already exists, Archgate merges its configuration additively -- existing MCP server entries are preserved. + +The MCP configuration: + +```json +{ + "mcpServers": { + "archgate": { + "command": "archgate", + "args": ["mcp"] + } + } +} +``` + +### Manual installation + +If the `copilot` CLI is not found during `archgate init`, you can install the plugin manually: + +```bash +copilot plugin install https://:@plugins.archgate.dev/archgate.git +``` + +You can find your authenticated URL by running `archgate login` and checking `~/.archgate/credentials`. + +## What the plugin provides + +The plugin adds role-based skills to Copilot CLI. Each skill encapsulates a specific part of the governance workflow, so agents follow the same process every time. + +### Skills included + +| Skill | Purpose | +| -------------------------- | ------------------------------------------------------------------------------------- | +| `archgate:developer` | General development agent that reads ADRs before coding and validates after | +| `archgate:architect` | Validates code changes against all project ADRs for structural compliance | +| `archgate:quality-manager` | Reviews rule coverage and proposes new ADRs when patterns emerge | +| `archgate:adr-author` | Creates and edits ADRs following project conventions | +| `archgate:onboard` | One-time setup: explores the codebase, interviews the developer, creates initial ADRs | + +## Initial setup with onboard + +After installation, run the `archgate:onboard` skill in your project once. This skill: + +1. Explores your codebase structure (directories, key files, package configuration) +2. Interviews you about your team's conventions, constraints, and architectural decisions +3. Creates an initial set of ADRs based on your responses +4. Sets up the `.archgate/` directory with your first rules + +The onboard skill is designed to run once per project. After onboarding, the other skills handle day-to-day development. + +## How it works in practice + +The plugin follows a structured workflow for every coding task: + +### 1. Read applicable ADRs + +When the developer gives a coding task, the agent reads all ADRs that apply to the files being changed. The MCP server provides a condensed briefing with the **Decision** and **Do's and Don'ts** sections from each relevant ADR. + +### 2. Write code following ADR constraints + +The agent writes code that complies with the constraints from the ADRs. The Do's and Don'ts sections serve as concrete guardrails. + +### 3. Validate changes + +After writing code, the agent runs `archgate check` to execute automated rules against the changes. Any violations are fixed before proceeding. + +### 4. Architect review + +The agent invokes `archgate:architect` to validate structural ADR compliance beyond what automated rules catch. + +### 5. Capture learnings + +The agent invokes `archgate:quality-manager` to review the work and identify patterns worth capturing as new ADRs. + +## MCP server integration + +The plugin communicates with your project's ADRs through the Archgate MCP server. The server provides: + +- **ADR content** -- full text of any ADR, accessible by ID +- **Review context** -- condensed briefings of all ADRs applicable to a set of changed files +- **Rule checking** -- execution of automated rules with violation reporting +- **ADR listing** -- inventory of all ADRs in the project with metadata + +The MCP server runs locally and reads directly from your `.archgate/adrs/` directory. No data leaves your machine. + +## Tips + +- **Commit `.github/copilot/mcp.json`** to share the MCP configuration with your team. +- **Run onboard once per project** to generate your initial ADRs from your actual codebase. +- **Keep ADR rule files up to date** -- the agent enforces what the rules check for. diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx new file mode 100644 index 00000000..197034af --- /dev/null +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -0,0 +1,165 @@ +--- +title: VS Code Plugin +description: Install the Archgate plugin in VS Code for AI-assisted development with governance. +--- + +The Archgate VS Code plugin gives AI agents working in [VS Code](https://code.visualstudio.com/) a structured governance workflow. Agents read your ADRs before writing code, validate after, and capture new patterns for the team -- the same workflow available in the [Claude Code plugin](/guides/claude-code-plugin/). + +## How it works + +VS Code supports **agent plugins** installed from git-based marketplaces. The Archgate plugin is served from a git repository at `plugins.archgate.dev/archgate-vscode.git`. When you add this marketplace to your VS Code user settings, VS Code discovers and installs the plugin automatically. + +The plugin is served in VS Code Copilot's native `.github/plugin/` manifest format, separate from the Claude Code `.claude-plugin/` format. + +## Installation + +:::caution[VS Code version requirement] +Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlier versions do not support git-based agent plugin marketplaces. Check your version with `code --version`. +::: + +:::note[Beta access required] +The VS Code plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +::: + +### 1. Log in with GitHub + +Authenticate with your GitHub account to obtain a plugin token: + +```bash +archgate login +``` + +This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored in `~/.archgate/credentials`. + +### 2. Initialize your project with the plugin + +Run `archgate init` with the `--editor vscode` flag: + +```bash +archgate init --editor vscode +``` + +If you are already logged in, this command: + +1. Creates the `.archgate/` governance directory (ADRs, lint rules) +2. Creates `.vscode/mcp.json` with the Archgate MCP server configuration +3. Adds the authenticated marketplace URL to your VS Code **user settings** + +The `chat.plugins.marketplaces` setting is application-scoped in VS Code, so it cannot be set per-workspace. The CLI automatically writes it to your user-level `settings.json`: + +| Platform | User settings path | +| -------- | ------------------------------------------------------- | +| Windows | `%APPDATA%\Code\User\settings.json` | +| macOS | `~/Library/Application Support/Code/User/settings.json` | +| Linux | `~/.config/Code/User/settings.json` | + +To explicitly request plugin installation: + +```bash +archgate init --editor vscode --install-plugin +``` + +### Generated files + +The command creates or updates the following files: + +| File | Scope | Purpose | +| -------------------- | --------- | -------------------------------------------------------- | +| `.vscode/mcp.json` | Workspace | MCP server configuration so VS Code can access your ADRs | +| User `settings.json` | User | `chat.plugins.marketplaces` with the authenticated URL | + +If `.vscode/mcp.json` already exists, Archgate merges its configuration additively -- existing server entries are preserved. The user settings file is also merged additively -- existing settings are never overwritten. + +The workspace MCP configuration (`.vscode/mcp.json`): + +```json +{ + "servers": { + "archgate": { + "command": "archgate", + "args": ["mcp"] + } + } +} +``` + +The user-level marketplace setting (added to your `settings.json`): + +```json +{ + "chat.plugins.marketplaces": [ + "https://:@plugins.archgate.dev/archgate-vscode.git" + ] +} +``` + +### Manual setup + +If you prefer not to let the CLI modify your user settings, you can add the marketplace URL manually. Open VS Code's user settings JSON (`Ctrl+Shift+P` → "Preferences: Open User Settings (JSON)") and add the `chat.plugins.marketplaces` entry shown above. You can find your authenticated URL by running `archgate login` and checking `~/.archgate/credentials`. + +## What the plugin provides + +The plugin adds role-based skills to VS Code's AI agent. Each skill encapsulates a specific part of the governance workflow, so agents follow the same process every time. + +### Skills included + +| Skill | Purpose | +| -------------------------- | ------------------------------------------------------------------------------------- | +| `archgate:developer` | General development agent that reads ADRs before coding and validates after | +| `archgate:architect` | Validates code changes against all project ADRs for structural compliance | +| `archgate:quality-manager` | Reviews rule coverage and proposes new ADRs when patterns emerge | +| `archgate:adr-author` | Creates and edits ADRs following project conventions | +| `archgate:onboard` | One-time setup: explores the codebase, interviews the developer, creates initial ADRs | + +## Initial setup with onboard + +After installation, run the `archgate:onboard` skill in your project once. This skill: + +1. Explores your codebase structure (directories, key files, package configuration) +2. Interviews you about your team's conventions, constraints, and architectural decisions +3. Creates an initial set of ADRs based on your responses +4. Sets up the `.archgate/` directory with your first rules + +The onboard skill is designed to run once per project. After onboarding, the other skills handle day-to-day development. + +## How it works in practice + +The plugin follows a structured workflow for every coding task: + +### 1. Read applicable ADRs + +When the developer gives a coding task, the agent reads all ADRs that apply to the files being changed. The MCP server provides a condensed briefing with the **Decision** and **Do's and Don'ts** sections from each relevant ADR. + +### 2. Write code following ADR constraints + +The agent writes code that complies with the constraints from the ADRs. The Do's and Don'ts sections serve as concrete guardrails. + +### 3. Validate changes + +After writing code, the agent runs `archgate check` to execute automated rules against the changes. Any violations are fixed before proceeding. + +### 4. Architect review + +The agent invokes `archgate:architect` to validate structural ADR compliance beyond what automated rules catch. + +### 5. Capture learnings + +The agent invokes `archgate:quality-manager` to review the work and identify patterns worth capturing as new ADRs. + +## MCP server integration + +The plugin communicates with your project's ADRs through the Archgate MCP server. The server provides: + +- **ADR content** -- full text of any ADR, accessible by ID +- **Review context** -- condensed briefings of all ADRs applicable to a set of changed files +- **Rule checking** -- execution of automated rules with violation reporting +- **ADR listing** -- inventory of all ADRs in the project with metadata + +The MCP server runs locally and reads directly from your `.archgate/adrs/` directory. No data leaves your machine. + +## Tips + +- **Commit `.vscode/mcp.json`** to share the MCP configuration with your team. +- **Each developer runs `archgate init --editor vscode`** after `archgate login` to configure their user-level marketplace URL. +- **Run onboard once per project** to generate your initial ADRs from your actual codebase. +- **Keep ADR rule files up to date** -- the agent enforces what the rules check for. diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx new file mode 100644 index 00000000..4d67cf1b --- /dev/null +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -0,0 +1,147 @@ +--- +title: Plugin para Copilot CLI +description: Instale o plugin Archgate para GitHub Copilot CLI com governança de IA. +--- + +O plugin Archgate para Copilot CLI oferece aos agentes de IA que trabalham no [GitHub Copilot CLI](https://github.com/features/copilot) um fluxo de governança estruturado. Os agentes leem seus ADRs antes de escrever código, validam depois e capturam novos padrões para a equipe -- o mesmo fluxo disponível no [plugin para Claude Code](/pt-br/guides/claude-code-plugin/). + +## Como funciona + +O Copilot CLI suporta instalação de plugins a partir de repositórios git usando `copilot plugin install`. O plugin Archgate é servido a partir de um repositório git em `plugins.archgate.dev/archgate.git`, que o Copilot CLI reconhece nativamente -- o mesmo formato de manifesto `.claude-plugin/plugin.json` funciona tanto para o Claude Code quanto para o Copilot CLI. + +## Instalação + +:::note[Acesso beta necessário] +O plugin para Copilot CLI está atualmente em beta. [Cadastre-se em plugins.archgate.dev](https://plugins.archgate.dev) para obter acesso antes de seguir os passos abaixo. +::: + +### 1. Faça login com o GitHub + +Autentique-se com sua conta do GitHub para obter um token do plugin: + +```bash +archgate login +``` + +Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas em `~/.archgate/credentials`. + +### 2. Inicialize seu projeto com o plugin + +Execute `archgate init` com a flag `--editor copilot`: + +```bash +archgate init --editor copilot +``` + +Se você já estiver logado e o CLI `copilot` estiver no seu PATH, o plugin é instalado automaticamente via: + +```bash +copilot plugin install https://:@plugins.archgate.dev/archgate.git +``` + +Se o CLI `copilot` não for encontrado, o comando exibe o comando manual para você executar. + +Para solicitar explicitamente a instalação do plugin: + +```bash +archgate init --editor copilot --install-plugin +``` + +### Arquivos gerados + +O comando cria a seguinte configuração: + +| Arquivo | Propósito | +| -------------------------- | ------------------------------------------------------------- | +| `.github/copilot/mcp.json` | Conexão do servidor MCP para que o Copilot CLI acesse os ADRs | + +Se `.github/copilot/mcp.json` já existir, o Archgate mescla sua configuração de forma aditiva -- entradas de servidores MCP existentes são preservadas. + +A configuração MCP: + +```json +{ + "mcpServers": { + "archgate": { + "command": "archgate", + "args": ["mcp"] + } + } +} +``` + +### Instalação manual + +Se o CLI `copilot` não for encontrado durante `archgate init`, você pode instalar o plugin manualmente: + +```bash +copilot plugin install https://:@plugins.archgate.dev/archgate.git +``` + +Você pode encontrar sua URL autenticada executando `archgate login` e verificando `~/.archgate/credentials`. + +## O que o plugin oferece + +O plugin adiciona skills baseadas em papéis ao Copilot CLI. Cada skill encapsula uma parte específica do fluxo de governança, garantindo que os agentes sigam o mesmo processo todas as vezes. + +### Skills incluídas + +| Skill | Propósito | +| -------------------------- | ------------------------------------------------------------------------------------------ | +| `archgate:developer` | Agente de desenvolvimento geral que lê ADRs antes de codificar e valida depois | +| `archgate:architect` | Valida alterações de código contra todos os ADRs do projeto para conformidade estrutural | +| `archgate:quality-manager` | Revisa a cobertura de regras e propõe novos ADRs quando padrões emergem | +| `archgate:adr-author` | Cria e edita ADRs seguindo as convenções do projeto | +| `archgate:onboard` | Configuração única: explora o codebase, entrevista o desenvolvedor e cria os ADRs iniciais | + +## Configuração inicial com onboard + +Após a instalação, execute a skill `archgate:onboard` no seu projeto uma vez. Essa skill: + +1. Explora a estrutura do seu codebase (diretórios, arquivos-chave, configuração de pacotes) +2. Entrevista você sobre as convenções, restrições e decisões arquiteturais da sua equipe +3. Cria um conjunto inicial de ADRs com base nas suas respostas +4. Configura o diretório `.archgate/` com suas primeiras regras + +A skill de onboard foi projetada para ser executada uma vez por projeto. Após o onboarding, as outras skills cuidam do desenvolvimento no dia a dia. + +## Como funciona na prática + +O plugin segue um fluxo estruturado para cada tarefa de codificação: + +### 1. Ler os ADRs aplicáveis + +Quando o desenvolvedor atribui uma tarefa de codificação, o agente lê todos os ADRs que se aplicam aos arquivos sendo alterados. O servidor MCP fornece um resumo condensado com as seções **Decision** e **Do's and Don'ts** de cada ADR relevante. + +### 2. Escrever código seguindo as restrições dos ADRs + +O agente escreve código que cumpre as restrições dos ADRs. As seções Do's and Don'ts servem como guardrails concretos. + +### 3. Validar as alterações + +Após escrever o código, o agente executa `archgate check` para rodar as regras automatizadas contra as alterações. Qualquer violação é corrigida antes de prosseguir. + +### 4. Revisão do arquiteto + +O agente invoca `archgate:architect` para validar a conformidade estrutural com os ADRs além do que as regras automatizadas capturam. + +### 5. Capturar aprendizados + +O agente invoca `archgate:quality-manager` para revisar o trabalho e identificar padrões que valem ser capturados como novos ADRs. + +## Integração com o servidor MCP + +O plugin se comunica com os ADRs do seu projeto por meio do servidor MCP do Archgate. O servidor fornece: + +- **Conteúdo de ADRs** -- texto completo de qualquer ADR, acessível por ID +- **Contexto de revisão** -- resumos condensados de todos os ADRs aplicáveis a um conjunto de arquivos alterados +- **Verificação de regras** -- execução de regras automatizadas com relatório de violações +- **Listagem de ADRs** -- inventário de todos os ADRs do projeto com metadados + +O servidor MCP roda localmente e lê diretamente do seu diretório `.archgate/adrs/`. Nenhum dado sai da sua máquina. + +## Dicas + +- **Faça commit do `.github/copilot/mcp.json`** para compartilhar a configuração MCP com sua equipe. +- **Execute o onboard uma vez por projeto** para gerar seus ADRs iniciais a partir do seu codebase real. +- **Mantenha os arquivos de regras dos ADRs atualizados** -- o agente aplica o que as regras verificam. diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx new file mode 100644 index 00000000..0b0eaa92 --- /dev/null +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -0,0 +1,165 @@ +--- +title: Plugin para VS Code +description: Instale o plugin Archgate no VS Code para desenvolvimento assistido por IA com governança. +--- + +O plugin Archgate para VS Code oferece aos agentes de IA que trabalham no [VS Code](https://code.visualstudio.com/) um fluxo de governança estruturado. Os agentes leem seus ADRs antes de escrever código, validam depois e capturam novos padrões para a equipe -- o mesmo fluxo disponível no [plugin para Claude Code](/pt-br/guides/claude-code-plugin/). + +## Como funciona + +O VS Code suporta **plugins de agente** instalados a partir de marketplaces baseados em git. O plugin Archgate é servido a partir de um repositório git em `plugins.archgate.dev/archgate-vscode.git`. Quando você adiciona esse marketplace às suas configurações de usuário do VS Code, o VS Code descobre e instala o plugin automaticamente. + +O plugin usa o mesmo formato dos plugins do Claude Code (`.claude-plugin/plugin.json`), que o sistema de plugins de agente do VS Code reconhece nativamente. + +## Instalação + +:::caution[Versão do VS Code necessária] +Plugins de agente requerem **VS Code 1.110 (lançamento de fevereiro de 2026) ou posterior**. Versões anteriores não suportam marketplaces de plugins de agente baseados em git. Verifique sua versão com `code --version`. +::: + +:::note[Acesso beta necessário] +O plugin para VS Code está atualmente em beta. [Cadastre-se em plugins.archgate.dev](https://plugins.archgate.dev) para obter acesso antes de seguir os passos abaixo. +::: + +### 1. Faça login com o GitHub + +Autentique-se com sua conta do GitHub para obter um token do plugin: + +```bash +archgate login +``` + +Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas em `~/.archgate/credentials`. + +### 2. Inicialize seu projeto com o plugin + +Execute `archgate init` com a flag `--editor vscode`: + +```bash +archgate init --editor vscode +``` + +Se você já estiver logado, este comando: + +1. Cria o diretório de governança `.archgate/` (ADRs, regras de lint) +2. Cria `.vscode/mcp.json` com a configuração do servidor MCP do Archgate +3. Adiciona a URL autenticada do marketplace nas suas **configurações de usuário** do VS Code + +A configuração `chat.plugins.marketplaces` tem escopo de aplicação no VS Code, então não pode ser definida por workspace. O CLI escreve automaticamente no seu `settings.json` de nível de usuário: + +| Plataforma | Caminho das configurações de usuário | +| ---------- | ------------------------------------------------------- | +| Windows | `%APPDATA%\Code\User\settings.json` | +| macOS | `~/Library/Application Support/Code/User/settings.json` | +| Linux | `~/.config/Code/User/settings.json` | + +Para solicitar explicitamente a instalação do plugin: + +```bash +archgate init --editor vscode --install-plugin +``` + +### Arquivos gerados + +O comando cria ou atualiza os seguintes arquivos: + +| Arquivo | Escopo | Propósito | +| -------------------------- | --------- | ------------------------------------------------------------------- | +| `.vscode/mcp.json` | Workspace | Configuração do servidor MCP para que o VS Code acesse os seus ADRs | +| `settings.json` do usuário | Usuário | `chat.plugins.marketplaces` com a URL autenticada | + +Se `.vscode/mcp.json` já existir, o Archgate mescla sua configuração de forma aditiva -- entradas de servidores existentes são preservadas. O arquivo de configurações de usuário também é mesclado de forma aditiva -- configurações existentes nunca são sobrescritas. + +A configuração MCP do workspace (`.vscode/mcp.json`): + +```json +{ + "servers": { + "archgate": { + "command": "archgate", + "args": ["mcp"] + } + } +} +``` + +A configuração do marketplace no nível de usuário (adicionada ao seu `settings.json`): + +```json +{ + "chat.plugins.marketplaces": [ + "https://:@plugins.archgate.dev/archgate-vscode.git" + ] +} +``` + +### Configuração manual + +Se preferir não deixar o CLI modificar suas configurações de usuário, você pode adicionar a URL do marketplace manualmente. Abra o JSON de configurações de usuário do VS Code (`Ctrl+Shift+P` → "Preferences: Open User Settings (JSON)") e adicione a entrada `chat.plugins.marketplaces` mostrada acima. Você pode encontrar sua URL autenticada executando `archgate login` e verificando `~/.archgate/credentials`. + +## O que o plugin oferece + +O plugin adiciona skills baseadas em papéis ao agente de IA do VS Code. Cada skill encapsula uma parte específica do fluxo de governança, garantindo que os agentes sigam o mesmo processo todas as vezes. + +### Skills incluídas + +| Skill | Propósito | +| -------------------------- | ------------------------------------------------------------------------------------------ | +| `archgate:developer` | Agente de desenvolvimento geral que lê ADRs antes de codificar e valida depois | +| `archgate:architect` | Valida alterações de código contra todos os ADRs do projeto para conformidade estrutural | +| `archgate:quality-manager` | Revisa a cobertura de regras e propõe novos ADRs quando padrões emergem | +| `archgate:adr-author` | Cria e edita ADRs seguindo as convenções do projeto | +| `archgate:onboard` | Configuração única: explora o codebase, entrevista o desenvolvedor e cria os ADRs iniciais | + +## Configuração inicial com onboard + +Após a instalação, execute a skill `archgate:onboard` no seu projeto uma vez. Essa skill: + +1. Explora a estrutura do seu codebase (diretórios, arquivos-chave, configuração de pacotes) +2. Entrevista você sobre as convenções, restrições e decisões arquiteturais da sua equipe +3. Cria um conjunto inicial de ADRs com base nas suas respostas +4. Configura o diretório `.archgate/` com suas primeiras regras + +A skill de onboard foi projetada para ser executada uma vez por projeto. Após o onboarding, as outras skills cuidam do desenvolvimento no dia a dia. + +## Como funciona na prática + +O plugin segue um fluxo estruturado para cada tarefa de codificação: + +### 1. Ler os ADRs aplicáveis + +Quando o desenvolvedor atribui uma tarefa de codificação, o agente lê todos os ADRs que se aplicam aos arquivos sendo alterados. O servidor MCP fornece um resumo condensado com as seções **Decision** e **Do's and Don'ts** de cada ADR relevante. + +### 2. Escrever código seguindo as restrições dos ADRs + +O agente escreve código que cumpre as restrições dos ADRs. As seções Do's and Don'ts servem como guardrails concretos. + +### 3. Validar as alterações + +Após escrever o código, o agente executa `archgate check` para rodar as regras automatizadas contra as alterações. Qualquer violação é corrigida antes de prosseguir. + +### 4. Revisão do arquiteto + +O agente invoca `archgate:architect` para validar a conformidade estrutural com os ADRs além do que as regras automatizadas capturam. + +### 5. Capturar aprendizados + +O agente invoca `archgate:quality-manager` para revisar o trabalho e identificar padrões que valem ser capturados como novos ADRs. + +## Integração com o servidor MCP + +O plugin se comunica com os ADRs do seu projeto por meio do servidor MCP do Archgate. O servidor fornece: + +- **Conteúdo de ADRs** -- texto completo de qualquer ADR, acessível por ID +- **Contexto de revisão** -- resumos condensados de todos os ADRs aplicáveis a um conjunto de arquivos alterados +- **Verificação de regras** -- execução de regras automatizadas com relatório de violações +- **Listagem de ADRs** -- inventário de todos os ADRs do projeto com metadados + +O servidor MCP roda localmente e lê diretamente do seu diretório `.archgate/adrs/`. Nenhum dado sai da sua máquina. + +## Dicas + +- **Faça commit do `.vscode/mcp.json`** para compartilhar a configuração MCP com sua equipe. +- **Cada desenvolvedor executa `archgate init --editor vscode`** após `archgate login` para configurar a URL do marketplace no nível de usuário. +- **Execute o onboard uma vez por projeto** para gerar seus ADRs iniciais a partir do seu codebase real. +- **Mantenha os arquivos de regras dos ADRs atualizados** -- o agente aplica o que as regras verificam. diff --git a/src/commands/init.ts b/src/commands/init.ts index 0c0c2692..50bfce12 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,7 +5,21 @@ import { initProject } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; import { loadCredentials } from "../helpers/auth"; -const VALID_EDITORS = ["claude", "cursor"] as const; +const VALID_EDITORS = ["claude", "cursor", "vscode", "copilot"] as const; + +const EDITOR_LABELS: Record = { + claude: "Claude Code", + cursor: "Cursor", + vscode: "VS Code", + copilot: "Copilot CLI", +}; + +const EDITOR_DIRS: Record = { + claude: ".claude/", + cursor: ".cursor/", + vscode: ".vscode/", + copilot: ".github/copilot/", +}; export function registerInitCommand(program: Command) { program @@ -13,7 +27,7 @@ export function registerInitCommand(program: Command) { .description("Initialize Archgate governance in the current project") .option( "--editor ", - "editor integration to configure (claude, cursor)", + "editor integration to configure (claude, cursor, vscode, copilot)", "claude" ) .option( @@ -39,38 +53,26 @@ export function registerInitCommand(program: Command) { installPlugin, }); + const editorTarget = editor as EditorTarget; + const label = EDITOR_LABELS[editorTarget]; + const dir = EDITOR_DIRS[editorTarget]; + console.log(`Initialized Archgate governance in ${result.projectRoot}`); console.log(` adrs/ - architecture decision records`); console.log(` lint/ - linter-specific rules`); - if (editor === "cursor") { - console.log(` .cursor/ - Cursor settings configured`); - } else { - console.log(` .claude/ - Claude Code settings configured`); - } + console.log(` ${dir.padEnd(13)}- ${label} settings configured`); // Plugin install output if (result.plugin?.installed) { console.log(""); if (result.plugin.autoInstalled) { - logInfo( - editor === "cursor" - ? "Archgate plugin installed for Cursor." - : "Archgate plugin installed for Claude Code." - ); + logInfo(`Archgate plugin installed for ${label}.`); if (result.plugin.detail) { console.log(` ${result.plugin.detail}`); } } else { - // Claude Code — claude CLI not found, show manual commands - logWarn( - "Claude CLI not found. To install the plugin manually, run:" - ); - console.log( - ` ${styleText("bold", "claude plugin marketplace add")} ${result.plugin.detail}` - ); - console.log( - ` ${styleText("bold", "claude plugin install")} archgate@archgate` - ); + // CLI not found for this editor — show manual commands + printManualInstructions(editorTarget, result.plugin.detail); } } else if (installPlugin) { // User wanted plugin but no credentials @@ -85,3 +87,27 @@ export function registerInitCommand(program: Command) { } }); } + +/** + * Print manual plugin installation instructions when the editor CLI is not available. + */ +function printManualInstructions(editor: EditorTarget, detail?: string): void { + switch (editor) { + case "claude": + logWarn("Claude CLI not found. To install the plugin manually, run:"); + console.log( + ` ${styleText("bold", "claude plugin marketplace add")} ${detail}` + ); + console.log( + ` ${styleText("bold", "claude plugin install")} archgate@archgate` + ); + break; + case "copilot": + logWarn("Copilot CLI not found. To install the plugin manually, run:"); + console.log(` ${styleText("bold", "copilot plugin install")} ${detail}`); + break; + default: + // cursor auto-installs always — should not reach here + break; + } +} diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 5bec1e46..c4a6c9ec 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,5 +1,5 @@ import type { Command } from "@commander-js/extra-typings"; -import { $, semver } from "bun"; +import { semver } from "bun"; import { logError } from "../helpers/log"; const NPM_REGISTRY = "https://registry.npmjs.org/archgate/latest"; @@ -58,9 +58,13 @@ export function registerUpgradeCommand(program: Command) { console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - const result = await $`npm install -g archgate@latest`.nothrow(); + const proc = Bun.spawn(["npm", "install", "-g", "archgate@latest"], { + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; - if (result.exitCode !== 0) { + if (exitCode !== 0) { logError( "Failed to install the latest version via npm.", "Try running `npm install -g archgate@latest` manually." diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 589c00d5..07464250 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -1,15 +1,32 @@ /** Git file-listing utilities for ADR scope resolution and change detection. */ +/** + * Run a git command using Bun.spawn (cross-platform, no shell). + * Bun.$ hangs on Windows due to pipe handling issues — this is the safe alternative. + */ +async function runGit(args: string[], cwd: string): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const text = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`git ${args[0]} exited with code ${exitCode}`); + } + return text; +} + /** Get all git-tracked (non-ignored) files in the project. */ export async function getGitTrackedFiles( projectRoot: string ): Promise | null> { try { - const result = - await Bun.$`git ls-files --cached --others --exclude-standard` - .cwd(projectRoot) - .quiet() - .text(); + const result = await runGit( + ["ls-files", "--cached", "--others", "--exclude-standard"], + projectRoot + ); return new Set(result.trim().split("\n").filter(Boolean)); } catch { return null; @@ -40,10 +57,10 @@ export async function resolveScopedFiles( /** Get changed files from git staging area. */ export async function getStagedFiles(projectRoot: string): Promise { try { - const result = await Bun.$`git diff --cached --name-only` - .cwd(projectRoot) - .quiet() - .text(); + const result = await runGit( + ["diff", "--cached", "--name-only"], + projectRoot + ); return result.trim().split("\n").filter(Boolean); } catch { return []; @@ -53,14 +70,11 @@ export async function getStagedFiles(projectRoot: string): Promise { /** Get all changed files (staged + unstaged). */ export async function getChangedFiles(projectRoot: string): Promise { try { - const staged = await Bun.$`git diff --cached --name-only` - .cwd(projectRoot) - .quiet() - .text(); - const unstaged = await Bun.$`git diff --name-only` - .cwd(projectRoot) - .quiet() - .text(); + const staged = await runGit( + ["diff", "--cached", "--name-only"], + projectRoot + ); + const unstaged = await runGit(["diff", "--name-only"], projectRoot); const all = new Set([ ...staged.trim().split("\n").filter(Boolean), ...unstaged.trim().split("\n").filter(Boolean), diff --git a/src/helpers/copilot-settings.ts b/src/helpers/copilot-settings.ts new file mode 100644 index 00000000..e8a4afdf --- /dev/null +++ b/src/helpers/copilot-settings.ts @@ -0,0 +1,77 @@ +import { join } from "node:path"; +import { existsSync, mkdirSync } from "node:fs"; + +/** + * MCP server configuration that archgate injects for Copilot CLI. + * + * Copilot CLI uses the same `.github/copilot/mcp.json` format for MCP servers + * and supports git-based plugin repositories via `copilot plugin install`. + */ +export const ARCHGATE_COPILOT_MCP_CONFIG = { + mcpServers: { + archgate: { + command: "archgate", + args: ["mcp"], + }, + }, +} as const; + +type CopilotMcpConfig = Record; + +/** + * Pure, additive merge of archgate MCP server config into existing Copilot MCP config. + * + * - Preserves all existing MCP server entries + * - Adds the archgate server only if not already present + */ +export function mergeCopilotMcpConfig( + existing: CopilotMcpConfig, + archgate: typeof ARCHGATE_COPILOT_MCP_CONFIG +): CopilotMcpConfig { + const existingServers = + typeof existing.mcpServers === "object" && + existing.mcpServers !== null && + !Array.isArray(existing.mcpServers) + ? (existing.mcpServers as Record) + : {}; + + return { + ...existing, + mcpServers: { + ...existingServers, + ...archgate.mcpServers, + }, + }; +} + +/** + * Configure Copilot CLI settings for archgate integration. + * + * Creates/updates `.github/copilot/mcp.json` with archgate MCP server. + * + * @returns Absolute path to the MCP config file. + */ +export async function configureCopilotSettings( + projectRoot: string +): Promise { + const copilotDir = join(projectRoot, ".github", "copilot"); + const mcpConfigPath = join(copilotDir, "mcp.json"); + + // Read existing MCP config or start with empty object + let existing: CopilotMcpConfig = {}; + if (existsSync(mcpConfigPath)) { + const content = await Bun.file(mcpConfigPath).text(); + existing = JSON.parse(content) as CopilotMcpConfig; + } + + const merged = mergeCopilotMcpConfig(existing, ARCHGATE_COPILOT_MCP_CONFIG); + + // Ensure directories exist + if (!existsSync(copilotDir)) { + mkdirSync(copilotDir, { recursive: true }); + } + + await Bun.write(mcpConfigPath, JSON.stringify(merged, null, 2) + "\n"); + + return mcpConfigPath; +} diff --git a/src/helpers/git.ts b/src/helpers/git.ts index 4c600fd4..8b22444e 100644 --- a/src/helpers/git.ts +++ b/src/helpers/git.ts @@ -1,19 +1,25 @@ -import { $ } from "bun"; import { logDebug } from "./log"; -export function installGit() { +export async function installGit() { if (Bun.which("git")) { logDebug("Git is already installed"); return; } console.log("Git is not installed. Installing..."); - if (process.platform === "darwin") return $`brew install git`; - if (process.platform === "linux") return $`sudo apt-get install -y git`; - if (process.platform === "win32") + if (process.platform === "win32") { throw new Error( "Git is not installed. Install it from https://git-scm.com/download/win and make sure it is on your PATH." ); - throw new Error("Unsupported platform"); + } + const cmd = + process.platform === "darwin" + ? ["brew", "install", "git"] + : ["sudo", "apt-get", "install", "-y", "git"]; + const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Failed to install git (exit code ${exitCode})`); + } } /** @@ -21,12 +27,25 @@ export function installGit() { */ export async function getChangedFiles(projectRoot: string): Promise { try { - const result = - await $`git diff --name-only && git diff --cached --name-only` - .cwd(projectRoot) - .quiet() - .text(); - const files = result.trim().split("\n").filter(Boolean); + const spawnOpts = { + cwd: projectRoot, + stdout: "pipe" as const, + stderr: "pipe" as const, + }; + const unstaged = Bun.spawn(["git", "diff", "--name-only"], spawnOpts); + const staged = Bun.spawn( + ["git", "diff", "--cached", "--name-only"], + spawnOpts + ); + const [unstagedText, stagedText] = await Promise.all([ + new Response(unstaged.stdout).text(), + new Response(staged.stdout).text(), + ]); + await Promise.all([unstaged.exited, staged.exited]); + const files = [ + ...unstagedText.trim().split("\n"), + ...stagedText.trim().split("\n"), + ].filter(Boolean); return [...new Set(files)]; } catch { return []; diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 4187c77e..15656d0f 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -4,8 +4,10 @@ import { createPathIfNotExists, projectPaths } from "./paths"; import { generateExampleAdr } from "./adr-templates"; import { configureClaudeSettings } from "./claude-settings"; import { configureCursorSettings } from "./cursor-settings"; +import { configureVscodeSettings } from "./vscode-settings"; +import { configureCopilotSettings } from "./copilot-settings"; -export type EditorTarget = "claude" | "cursor"; +export type EditorTarget = "claude" | "cursor" | "vscode" | "copilot"; export interface InitOptions { editor?: EditorTarget; @@ -86,10 +88,7 @@ Archgate standardizes \`.archgate/lint/\` as the location for linter rules that ); const editor = options?.editor ?? "claude"; - const editorSettingsPath = - editor === "cursor" - ? await configureCursorSettings(projectRoot) - : await configureClaudeSettings(projectRoot); + const editorSettingsPath = await configureEditorSettings(projectRoot, editor); // Plugin installation (optional — requires stored credentials) let plugin: PluginResult | undefined; @@ -106,6 +105,32 @@ Archgate standardizes \`.archgate/lint/\` as the location for linter rules that }; } +/** + * Route editor settings configuration to the appropriate helper. + */ +async function configureEditorSettings( + projectRoot: string, + editor: EditorTarget +): Promise { + switch (editor) { + case "cursor": + return configureCursorSettings(projectRoot); + case "vscode": { + // VS Code: .vscode/mcp.json always, marketplace URL to user settings if logged in + const { loadCredentials } = await import("./auth"); + const creds = await loadCredentials(); + const marketplaceUrl = creds + ? (await import("./plugin-install")).buildVscodeMarketplaceUrl(creds) + : undefined; + return configureVscodeSettings(projectRoot, marketplaceUrl); + } + case "copilot": + return configureCopilotSettings(projectRoot); + default: + return configureClaudeSettings(projectRoot); + } +} + /** * Attempt to install the archgate plugin using stored credentials. * Returns null-safe result — never throws. @@ -130,6 +155,33 @@ async function tryInstallPlugin( }; } + if (editor === "vscode") { + // VS Code marketplace URL is already added to user settings by configureEditorSettings. + // The --install-plugin flag is a no-op for VS Code since init handles everything. + return { + installed: true, + autoInstalled: true, + detail: "Marketplace URL added to VS Code user settings", + }; + } + + if (editor === "copilot") { + const { isCopilotCliAvailable, installCopilotPlugin, buildMarketplaceUrl } = + await import("./plugin-install"); + + if (await isCopilotCliAvailable()) { + try { + await installCopilotPlugin(credentials); + return { installed: true, autoInstalled: true }; + } catch { + // Fall through to manual instructions + } + } + + const url = buildMarketplaceUrl(credentials); + return { installed: true, detail: url }; + } + // Claude Code — try auto-install via `claude` CLI, fall back to manual URL const { isClaudeCliAvailable, installClaudePlugin, buildMarketplaceUrl } = await import("./plugin-install"); diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 8058d6fc..035b9a2c 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -2,34 +2,68 @@ * plugin-install.ts — Download and install the archgate plugin for supported editors. * * - Claude Code: auto-installs via `claude` CLI, or prints manual commands as fallback + * - VS Code: marketplace URL for manual user-settings configuration (application-scoped) + * - Copilot CLI: auto-installs via `copilot` CLI, or prints manual commands as fallback * - Cursor: downloads cursor.tar.gz from the plugins service and extracts it */ import { join } from "node:path"; import { mkdirSync, unlinkSync } from "node:fs"; -import { $ } from "bun"; import { logDebug } from "./log"; import type { StoredCredentials } from "./auth"; const PLUGINS_API = "https://plugins.archgate.dev"; +/** + * Run a command using Bun.spawn (cross-platform, no shell). + * Returns { exitCode, stdout }. + */ +async function run( + cmd: string[], + opts?: { cwd?: string } +): Promise<{ exitCode: number; stdout: string }> { + const proc = Bun.spawn(cmd, { + cwd: opts?.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return { exitCode, stdout }; +} + // --------------------------------------------------------------------------- // Claude Code — CLI auto-install + manual fallback // --------------------------------------------------------------------------- /** - * Build the authenticated git marketplace URL for Claude Code plugin installation. + * Build the authenticated git marketplace URL for Claude Code & Copilot CLI plugin installation. + * Claude Code and Copilot CLI both use the .claude-plugin/ manifest format. */ export function buildMarketplaceUrl(credentials: StoredCredentials): string { return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate.git`; } +/** + * Build the authenticated git marketplace URL for VS Code plugin installation. + * VS Code Copilot uses the .github/plugin/ manifest format, served from a separate repo. + */ +export function buildVscodeMarketplaceUrl( + credentials: StoredCredentials +): string { + return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate-vscode.git`; +} + /** * Check whether the `claude` CLI is available on the system PATH. */ export async function isClaudeCliAvailable(): Promise { - const result = await $`claude --version`.nothrow().quiet(); - return result.exitCode === 0; + try { + const { exitCode } = await run(["claude", "--version"]); + return exitCode === 0; + } catch { + return false; + } } /** @@ -47,9 +81,7 @@ export async function installClaudePlugin( const url = buildMarketplaceUrl(credentials); logDebug("Adding archgate marketplace to claude CLI"); - const addResult = await $`claude plugin marketplace add ${url}` - .nothrow() - .quiet(); + const addResult = await run(["claude", "plugin", "marketplace", "add", url]); if (addResult.exitCode !== 0) { throw new Error( `claude plugin marketplace add failed (exit ${addResult.exitCode})` @@ -57,9 +89,12 @@ export async function installClaudePlugin( } logDebug("Installing archgate plugin via claude CLI"); - const installResult = await $`claude plugin install archgate@archgate` - .nothrow() - .quiet(); + const installResult = await run([ + "claude", + "plugin", + "install", + "archgate@archgate", + ]); if (installResult.exitCode !== 0) { throw new Error( `claude plugin install failed (exit ${installResult.exitCode})` @@ -113,6 +148,48 @@ export async function installCursorPlugin( return extractedFiles; } +// --------------------------------------------------------------------------- +// Copilot CLI — CLI auto-install + manual fallback +// --------------------------------------------------------------------------- + +/** + * Check whether the `copilot` CLI is available on the system PATH. + */ +export async function isCopilotCliAvailable(): Promise { + try { + const { exitCode } = await run(["copilot", "--version"]); + return exitCode === 0; + } catch { + return false; + } +} + +/** + * Install the archgate plugin via the `copilot` CLI. + * + * Runs: + * copilot plugin install + * + * Throws on failure so the caller can fall back to manual instructions. + */ +export async function installCopilotPlugin( + credentials: StoredCredentials +): Promise { + const url = buildMarketplaceUrl(credentials); + + logDebug("Installing archgate plugin via copilot CLI"); + const installResult = await run(["copilot", "plugin", "install", url]); + if (installResult.exitCode !== 0) { + throw new Error( + `copilot plugin install failed (exit ${installResult.exitCode})` + ); + } +} + +// --------------------------------------------------------------------------- +// Shared — tar extraction helper +// --------------------------------------------------------------------------- + /** * Extract a .tar.gz buffer to a destination directory. * Uses system tar (available on macOS, Linux, and Windows 10+). @@ -129,7 +206,7 @@ async function extractTarGz( mkdirSync(destDir, { recursive: true }); // Extract using tar (available on macOS, Linux, and Windows 10+) - const result = await $`tar -xzf ${tmpArchive} -C ${destDir}`.nothrow(); + const result = await run(["tar", "-xzf", tmpArchive, "-C", destDir]); if (result.exitCode !== 0) { throw new Error( @@ -138,8 +215,8 @@ async function extractTarGz( } // List extracted files for reporting - const listResult = await $`tar -tzf ${tmpArchive}`.nothrow().text(); - const files = listResult + const listResult = await run(["tar", "-tzf", tmpArchive]); + const files = listResult.stdout .split("\n") .map((f) => f.trim()) .filter(Boolean); diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts new file mode 100644 index 00000000..7bf8a773 --- /dev/null +++ b/src/helpers/vscode-settings.ts @@ -0,0 +1,196 @@ +import { join } from "node:path"; +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; + +/** + * MCP server configuration that archgate injects into .vscode/mcp.json. + * + * VS Code uses a dedicated `.vscode/mcp.json` file for MCP server registration + * (not `.vscode/settings.json`). The `servers` key format is defined by VS Code's + * MCP configuration spec. + */ +export const ARCHGATE_VSCODE_MCP_CONFIG = { + servers: { + archgate: { + command: "archgate", + args: ["mcp"], + }, + }, +} as const; + +type VscodeMcpConfig = Record; +type VscodeUserSettings = Record; + +/** + * Pure, additive merge of archgate MCP server config into existing VS Code MCP config. + * + * - Preserves all existing MCP server entries + * - Adds the archgate server (overwrites if already present) + */ +export function mergeVscodeMcpConfig( + existing: VscodeMcpConfig, + archgate: typeof ARCHGATE_VSCODE_MCP_CONFIG +): VscodeMcpConfig { + const existingServers = + typeof existing.servers === "object" && + existing.servers !== null && + !Array.isArray(existing.servers) + ? (existing.servers as Record) + : {}; + + return { + ...existing, + servers: { + ...existingServers, + ...archgate.servers, + }, + }; +} + +/** + * VS Code's built-in default marketplaces for `chat.plugins.marketplaces`. + * + * These are implicit defaults — VS Code uses them when the setting is absent + * from the user's settings.json. Once we explicitly set the key, VS Code stops + * using its defaults, so we must include them to avoid losing them. + */ +const VSCODE_DEFAULT_MARKETPLACES = [ + "github/copilot-plugins", + "github/awesome-copilot", +]; + +/** + * Deduplicate an array of strings while preserving order. + */ +function dedup(arr: string[]): string[] { + return [...new Set(arr)]; +} + +/** + * Add a marketplace URL to the `chat.plugins.marketplaces` array in a VS Code + * user settings object. Preserves all other settings. Deduplicates URLs. + * + * When `chat.plugins.marketplaces` is not yet in the file, VS Code's built-in + * defaults are included so they are not lost when we explicitly set the key. + */ +export function mergeMarketplaceUrl( + existing: VscodeUserSettings, + marketplaceUrl: string +): VscodeUserSettings { + const merged: VscodeUserSettings = { ...existing }; + + const hasExplicitMarketplaces = "chat.plugins.marketplaces" in existing; + const existingMarketplaces = Array.isArray( + merged["chat.plugins.marketplaces"] + ) + ? (merged["chat.plugins.marketplaces"] as string[]) + : []; + + // When the key is absent, seed with VS Code's built-in defaults so we don't + // silently override them by setting the key explicitly. + const base = hasExplicitMarketplaces + ? existingMarketplaces + : [...VSCODE_DEFAULT_MARKETPLACES, ...existingMarketplaces]; + + merged["chat.plugins.marketplaces"] = dedup([...base, marketplaceUrl]); + + return merged; +} + +/** + * Resolve the path to VS Code's user-level settings.json. + * + * - Windows: %APPDATA%/Code/User/settings.json + * - macOS: ~/Library/Application Support/Code/User/settings.json + * - Linux: ~/.config/Code/User/settings.json + */ +export function getVscodeUserSettingsPath(): string { + const platform = process.platform; + if (platform === "win32") { + const appData = + process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); + return join(appData, "Code", "User", "settings.json"); + } + if (platform === "darwin") { + return join( + homedir(), + "Library", + "Application Support", + "Code", + "User", + "settings.json" + ); + } + // Linux and others + return join(homedir(), ".config", "Code", "User", "settings.json"); +} + +/** + * Configure VS Code settings for archgate integration. + * + * 1. Creates/updates `.vscode/mcp.json` (workspace-level) with the Archgate MCP server. + * 2. If `marketplaceUrl` is provided, adds it to `chat.plugins.marketplaces` in + * the VS Code user-level settings.json (application-scoped — cannot be set per workspace). + * + * @returns Absolute path to the workspace MCP config file. + */ +export async function configureVscodeSettings( + projectRoot: string, + marketplaceUrl?: string +): Promise { + const vscodeDir = join(projectRoot, ".vscode"); + const mcpConfigPath = join(vscodeDir, "mcp.json"); + + // --- Workspace: .vscode/mcp.json --- + let existing: VscodeMcpConfig = {}; + if (existsSync(mcpConfigPath)) { + const content = await Bun.file(mcpConfigPath).text(); + existing = Bun.JSONC.parse(content) as VscodeMcpConfig; + } + + const merged = mergeVscodeMcpConfig(existing, ARCHGATE_VSCODE_MCP_CONFIG); + + if (!existsSync(vscodeDir)) { + mkdirSync(vscodeDir, { recursive: true }); + } + + await Bun.write(mcpConfigPath, JSON.stringify(merged, null, 2) + "\n"); + + // --- User-level: chat.plugins.marketplaces --- + if (marketplaceUrl) { + await addMarketplaceToUserSettings(marketplaceUrl); + } + + return mcpConfigPath; +} + +/** + * Add the marketplace URL to VS Code's user-level settings.json. + * + * Reads the existing file with `Bun.JSONC.parse` (supports comments and + * trailing commas), merges the marketplace URL, and writes back as standard + * JSON. Comments in the original file are not preserved — VS Code re-reads + * the file without issue. + */ +export async function addMarketplaceToUserSettings( + marketplaceUrl: string +): Promise { + const settingsPath = getVscodeUserSettingsPath(); + const settingsDir = join(settingsPath, ".."); + + let existing: VscodeUserSettings = {}; + if (existsSync(settingsPath)) { + const content = await Bun.file(settingsPath).text(); + existing = Bun.JSONC.parse(content) as VscodeUserSettings; + } + + const merged = mergeMarketplaceUrl(existing, marketplaceUrl); + + if (!existsSync(settingsDir)) { + mkdirSync(settingsDir, { recursive: true }); + } + + await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + "\n"); + + return settingsPath; +} diff --git a/tests/helpers/copilot-settings.test.ts b/tests/helpers/copilot-settings.test.ts new file mode 100644 index 00000000..8cc77e94 --- /dev/null +++ b/tests/helpers/copilot-settings.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + ARCHGATE_COPILOT_MCP_CONFIG, + mergeCopilotMcpConfig, + configureCopilotSettings, +} from "../../src/helpers/copilot-settings"; + +describe("mergeCopilotMcpConfig", () => { + test("sets archgate server when existing config is empty", () => { + const result = mergeCopilotMcpConfig({}, ARCHGATE_COPILOT_MCP_CONFIG); + + expect(result.mcpServers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); + + test("preserves existing MCP servers", () => { + const result = mergeCopilotMcpConfig( + { + mcpServers: { + "other-server": { + command: "other", + args: ["start"], + }, + }, + }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + const servers = result.mcpServers as Record; + expect(servers["other-server"]).toEqual({ + command: "other", + args: ["start"], + }); + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("overwrites existing archgate server entry", () => { + const result = mergeCopilotMcpConfig( + { + mcpServers: { + archgate: { + command: "old-command", + args: ["old"], + }, + }, + }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + const servers = result.mcpServers as Record; + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("preserves unknown top-level keys", () => { + const result = mergeCopilotMcpConfig( + { customKey: "value" }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + expect(result.customKey).toBe("value"); + }); + + test("handles non-object mcpServers gracefully", () => { + const result = mergeCopilotMcpConfig( + { mcpServers: "invalid" }, + ARCHGATE_COPILOT_MCP_CONFIG + ); + + expect(result.mcpServers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); +}); + +describe("configureCopilotSettings", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-copilot-settings-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("creates .github/copilot/ dir and mcp.json when nothing exists", async () => { + const mcpConfigPath = await configureCopilotSettings(tempDir); + + expect(existsSync(join(tempDir, ".github", "copilot"))).toBe(true); + expect(existsSync(mcpConfigPath)).toBe(true); + + const mcpContent = JSON.parse(await Bun.file(mcpConfigPath).text()); + expect(mcpContent.mcpServers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("merges into existing mcp.json without overwriting other servers", async () => { + const copilotDir = join(tempDir, ".github", "copilot"); + mkdirSync(copilotDir, { recursive: true }); + + const existingConfig = { + mcpServers: { + "my-server": { command: "my-cmd", args: [] }, + }, + }; + await Bun.write( + join(copilotDir, "mcp.json"), + JSON.stringify(existingConfig, null, 2) + ); + + await configureCopilotSettings(tempDir); + + const content = JSON.parse( + await Bun.file(join(copilotDir, "mcp.json")).text() + ); + expect(content.mcpServers["my-server"]).toEqual({ + command: "my-cmd", + args: [], + }); + expect(content.mcpServers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("returns correct absolute path to mcp.json", async () => { + const mcpConfigPath = await configureCopilotSettings(tempDir); + + expect(mcpConfigPath).toBe(join(tempDir, ".github", "copilot", "mcp.json")); + }); +}); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts new file mode 100644 index 00000000..5c04ca90 --- /dev/null +++ b/tests/helpers/vscode-settings.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + ARCHGATE_VSCODE_MCP_CONFIG, + mergeVscodeMcpConfig, + mergeMarketplaceUrl, + configureVscodeSettings, + addMarketplaceToUserSettings, + getVscodeUserSettingsPath, +} from "../../src/helpers/vscode-settings"; + +describe("mergeVscodeMcpConfig", () => { + test("sets archgate server when existing config is empty", () => { + const result = mergeVscodeMcpConfig({}, ARCHGATE_VSCODE_MCP_CONFIG); + + expect(result.servers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); + + test("preserves existing MCP servers", () => { + const result = mergeVscodeMcpConfig( + { + servers: { + "other-server": { + command: "other", + args: ["start"], + }, + }, + }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + const servers = result.servers as Record; + expect(servers["other-server"]).toEqual({ + command: "other", + args: ["start"], + }); + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("overwrites existing archgate server entry", () => { + const result = mergeVscodeMcpConfig( + { + servers: { + archgate: { + command: "old-command", + args: ["old"], + }, + }, + }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + const servers = result.servers as Record; + expect(servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("preserves unknown top-level keys", () => { + const result = mergeVscodeMcpConfig( + { inputs: [{ type: "promptString", id: "key" }] }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + expect(result.inputs).toEqual([{ type: "promptString", id: "key" }]); + }); + + test("handles non-object servers gracefully", () => { + const result = mergeVscodeMcpConfig( + { servers: "invalid" }, + ARCHGATE_VSCODE_MCP_CONFIG + ); + + expect(result.servers).toEqual({ + archgate: { + command: "archgate", + args: ["mcp"], + }, + }); + }); +}); + +describe("mergeMarketplaceUrl", () => { + const URL = "https://user:token@plugins.archgate.dev/archgate.git"; + + test("includes VS Code defaults when key is absent", () => { + const result = mergeMarketplaceUrl({}, URL); + expect(result["chat.plugins.marketplaces"]).toEqual([ + "github/copilot-plugins", + "github/awesome-copilot", + URL, + ]); + }); + + test("appends URL with dedup when key already exists", () => { + const result = mergeMarketplaceUrl( + { "chat.plugins.marketplaces": ["https://other.git", URL] }, + URL + ); + expect(result["chat.plugins.marketplaces"]).toEqual([ + "https://other.git", + URL, + ]); + }); + + test("does not re-add defaults when key is explicitly set", () => { + const result = mergeMarketplaceUrl( + { "chat.plugins.marketplaces": ["https://custom.git"] }, + URL + ); + expect(result["chat.plugins.marketplaces"]).toEqual([ + "https://custom.git", + URL, + ]); + }); + + test("handles non-array marketplaces gracefully", () => { + const result = mergeMarketplaceUrl( + { "chat.plugins.marketplaces": "not-an-array", "editor.fontSize": 14 }, + URL + ); + expect(result["chat.plugins.marketplaces"]).toEqual([URL]); + expect(result["editor.fontSize"]).toBe(14); + }); +}); + +describe("configureVscodeSettings", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-vscode-settings-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("creates .vscode/ dir and mcp.json when nothing exists", async () => { + const mcpConfigPath = await configureVscodeSettings(tempDir); + + expect(existsSync(join(tempDir, ".vscode"))).toBe(true); + expect(existsSync(mcpConfigPath)).toBe(true); + + const content = JSON.parse(await Bun.file(mcpConfigPath).text()); + expect(content.servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("merges into existing mcp.json without overwriting other servers", async () => { + const vscodeDir = join(tempDir, ".vscode"); + mkdirSync(vscodeDir, { recursive: true }); + + const existingConfig = { + servers: { + "my-server": { command: "my-cmd", args: [] }, + }, + }; + await Bun.write( + join(vscodeDir, "mcp.json"), + JSON.stringify(existingConfig, null, 2) + ); + + await configureVscodeSettings(tempDir); + + const content = JSON.parse( + await Bun.file(join(vscodeDir, "mcp.json")).text() + ); + expect(content.servers["my-server"]).toEqual({ + command: "my-cmd", + args: [], + }); + expect(content.servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("parses JSONC (comments + trailing commas) in existing mcp.json", async () => { + const vscodeDir = join(tempDir, ".vscode"); + mkdirSync(vscodeDir, { recursive: true }); + + // Write JSONC with comments and trailing comma — as VS Code produces + const jsoncContent = `{ + // MCP servers + "servers": { + "my-server": { "command": "my-cmd", "args": [] }, + } + }`; + await Bun.write(join(vscodeDir, "mcp.json"), jsoncContent); + + await configureVscodeSettings(tempDir); + + const content = JSON.parse( + await Bun.file(join(vscodeDir, "mcp.json")).text() + ); + expect(content.servers["my-server"]).toEqual({ + command: "my-cmd", + args: [], + }); + expect(content.servers.archgate).toEqual({ + command: "archgate", + args: ["mcp"], + }); + }); + + test("returns correct absolute path to mcp.json", async () => { + const mcpConfigPath = await configureVscodeSettings(tempDir); + + expect(mcpConfigPath).toBe(join(tempDir, ".vscode", "mcp.json")); + }); +}); + +describe("addMarketplaceToUserSettings", () => { + let tempDir: string; + let savedEnv: Record; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-user-settings-test-")); + // Save and override env so getVscodeUserSettingsPath() resolves into tempDir + savedEnv = { APPDATA: process.env.APPDATA, HOME: process.env.HOME }; + process.env.APPDATA = tempDir; // Windows + process.env.HOME = tempDir; // macOS/Linux (homedir()) + }); + + afterEach(() => { + Object.assign(process.env, savedEnv); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const URL = "https://user:token@plugins.archgate.dev/archgate.git"; + + /** Use the real path resolver so the test matches addMarketplaceToUserSettings */ + function settingsPath() { + return getVscodeUserSettingsPath(); + } + + test("creates settings file with defaults when none exists", async () => { + await addMarketplaceToUserSettings(URL); + + const content = JSON.parse(await Bun.file(settingsPath()).text()); + expect(content["chat.plugins.marketplaces"]).toEqual([ + "github/copilot-plugins", + "github/awesome-copilot", + URL, + ]); + }); + + test("merges JSONC settings and includes defaults when key absent", async () => { + mkdirSync(join(settingsPath(), ".."), { recursive: true }); + await Bun.write( + settingsPath(), + `{ "git.autofetch": true, "chat.mcp.gallery.enabled": true, }` + ); + + await addMarketplaceToUserSettings(URL); + + const content = JSON.parse(await Bun.file(settingsPath()).text()); + expect(content["git.autofetch"]).toBe(true); + expect(content["chat.plugins.marketplaces"]).toEqual([ + "github/copilot-plugins", + "github/awesome-copilot", + URL, + ]); + }); + + test("deduplicates when key already exists", async () => { + mkdirSync(join(settingsPath(), ".."), { recursive: true }); + await Bun.write( + settingsPath(), + JSON.stringify({ + "chat.plugins.marketplaces": ["https://other.git", URL], + }) + ); + + await addMarketplaceToUserSettings(URL); + + const content = JSON.parse(await Bun.file(settingsPath()).text()); + expect(content["chat.plugins.marketplaces"]).toEqual([ + "https://other.git", + URL, + ]); + }); +});