diff --git a/.archgate/adrs/ARCH-008-typed-command-options.md b/.archgate/adrs/ARCH-008-typed-command-options.md new file mode 100644 index 00000000..f09e3d71 --- /dev/null +++ b/.archgate/adrs/ARCH-008-typed-command-options.md @@ -0,0 +1,175 @@ +--- +id: ARCH-008 +title: Typed Command Options +domain: architecture +rules: true +files: ["src/commands/**/*.ts"] +--- + +## Context + +Commander.js `.option()` accepts arbitrary strings and produces loosely typed option values. This causes two problems: + +1. **Fixed choices** — When a command accepts a fixed set of values (e.g., `--editor claude|cursor|vscode|copilot`), using `.option()` requires manual runtime validation and `as` casts to narrow the type — boilerplate that is easy to forget and produces unhelpful error messages. +2. **Custom parsers** — Passing a parser function (e.g., `parseInt`) as the third argument to `.option()` loses type information. The `opts` object infers `string` instead of the parser's return type. Worse, passing `parseInt` directly is a subtle bug: Commander passes `(value, previous)` but `parseInt` interprets `previous` as `radix`. + +**Alternatives considered:** + +- **Plain `.option()` with manual validation** — The developer writes a runtime check (`if (!VALID.includes(val))`) and casts to the narrow type. This works but scatters validation logic, produces inconsistent error messages across commands, and the `opts` object remains typed as `string`, requiring `as` casts at every usage site. +- **Zod/custom parsing in `.option()` argParser** — Commander supports a custom parse function as the third argument to `.option()`. While this gives runtime validation, it does not narrow the TypeScript type in the `opts` object when using `@commander-js/extra-typings`. + +The `@commander-js/extra-typings` package provides the `Option` class with `.choices()` for enum-like options and `.argParser()` for custom parsers. Both methods correctly narrow the TypeScript type in the action callback's `opts` parameter. Using `.addOption()` instead of `.option()` integrates these typed options into the command. + +## Decision + +Options that require type narrowing beyond plain strings MUST use `new Option()` with `.addOption()` instead of plain `.option()`. + +**Key constraints:** + +1. **Use `Option` from `@commander-js/extra-typings`** — Import `Option` alongside `Command` from the extra-typings package to get full type inference. +2. **Use `.choices()` for enum-like options** — Any option accepting a fixed set of values must use `.choices()` to get both runtime validation and compile-time type narrowing. +3. **Use `.argParser()` for custom parsers** — Any option requiring type conversion (e.g., string to number) must use `.argParser()` on an `Option` object, not pass a parser function as the third argument to `.option()`. +4. **Use `.addOption()` to register** — The typed `Option` object is passed via `.addOption()`, not `.option()`. +5. **Use `as const` with `.choices()` and `.default()`** — Pass the choices array and default value with `as const` to preserve literal types. +6. **No manual validation for choice options** — Commander handles invalid value rejection automatically; do not duplicate this logic. + +## Do's and Don'ts + +### Do + +- Use `new Option().choices([...] as const).default(... as const)` for fixed-choice options +- Use `new Option().argParser((val) => ...)` for options that need type conversion +- Register typed options via `.addOption()` +- Reuse existing type definitions (e.g., `EditorTarget`) for `Record` keys and other type-level usage +- Access `opts.editor` directly in switch/case — TypeScript narrows the type + +### Don't + +- Don't use `.option()` for options with a known set of valid values +- Don't pass parser functions (e.g., `parseInt`) as the third argument to `.option()` — use `.argParser()` on an `Option` object instead +- Don't write manual `if (!VALID.includes(val))` checks for choice options — Commander does this +- Don't cast `opts.editor as SomeType` — the type should already be narrowed + +## Implementation Pattern + +### Good Example + +```typescript +import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; + +const editorOption = new Option("--editor ", "target editor") + .choices(["claude", "cursor", "vscode", "copilot"] as const) + .default("claude" as const); + +export function registerExampleCommand(program: Command) { + program + .command("example") + .addOption(editorOption) + .action(async (opts) => { + // opts.editor is typed as "claude" | "cursor" | "vscode" | "copilot" + switch (opts.editor) { + case "claude": + break; + case "cursor": + break; + // TypeScript enforces exhaustive matching + } + }); +} +``` + +### Good Example (argParser) + +```typescript +import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; + +const maxEntriesOption = new Option( + "--max-entries ", + "maximum entries to return" +).argParser((val) => parseInt(val, 10)); + +export function registerExampleCommand(program: Command) { + program + .command("example") + .addOption(maxEntriesOption) + .action(async (opts) => { + // opts.maxEntries is typed as number | undefined + const limit = opts.maxEntries ?? 200; + }); +} +``` + +### Bad Example (choices) + +```typescript +// BAD: loose typing, manual validation, casts +export function registerExampleCommand(program: Command) { + program + .command("example") + .option("--editor ", "target editor", "claude") + .action(async (opts) => { + // opts.editor is string — no narrowing + if (!["claude", "cursor"].includes(opts.editor)) { + logError(`Unknown editor "${opts.editor}"`); + process.exit(1); + } + const editor = opts.editor as EditorTarget; // unsafe cast + }); +} +``` + +### Bad Example (argParser) + +```typescript +// BAD: parseInt passed directly — previous value becomes radix, type is wrong +export function registerExampleCommand(program: Command) { + program + .command("example") + .option("--max-entries ", "maximum entries", parseInt) + .action(async (opts) => { + // opts.maxEntries type is not correctly inferred as number + // parseInt receives (value, previous) — previous becomes radix (bug) + }); +} +``` + +## Consequences + +### Positive + +- **Compile-time safety** — Invalid option values are caught by TypeScript, not just at runtime +- **Consistent error messages** — Commander produces standard error output for invalid choices +- **No boilerplate validation** — Eliminates repeated `if (!VALID.includes(...))` patterns +- **Exhaustive switch/case** — TypeScript ensures all choices are handled when switching on the option value + +### Negative + +- **Slightly more verbose declaration** — `new Option().choices().default()` with `.addOption()` is more code than `.option()` for simple cases. This is acceptable given the type safety gained. + +### Risks + +- **Regression** — A developer unfamiliar with this ADR may use `.option()` with manual validation or a bare parser function. The automated rules catch both patterns at check time. + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** ARCH-008/use-add-option-for-choices: Scans command files for `.option()` calls that include a hardcoded choices-like pattern and flags them for migration to `.addOption()` with `.choices()`. Severity: error. +- **Archgate rule** ARCH-008/use-add-option-for-arg-parser: Scans command files for `.option()` calls that pass a parser function (e.g., `parseInt`, `parseFloat`, or arrow functions) as the third argument, and flags them for migration to `new Option().argParser()` with `.addOption()`. Severity: error. + +### Manual Enforcement + +Code reviewers MUST verify: + +1. New commands with fixed-choice options use `new Option().choices()` with `.addOption()` +2. New commands with custom parsers use `new Option().argParser()` with `.addOption()` +3. The choices array and default use `as const` for literal type preservation +4. No manual validation duplicates Commander's built-in choice rejection + +## References + +- [Commander.js Option documentation](https://github.com/tj/commander.js#options) +- [@commander-js/extra-typings](https://github.com/tj/commander.js/tree/master/typings) — Typed Commander.js wrapper +- [ARCH-001 — Command Structure](./ARCH-001-command-structure.md) — Parent command registration pattern diff --git a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts new file mode 100644 index 00000000..478246d5 --- /dev/null +++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts @@ -0,0 +1,63 @@ +import { defineRules } from "../../src/formats/rules"; + +export default defineRules({ + "use-add-option-for-choices": { + description: + "Commands with fixed-choice options must use addOption with choices() instead of plain option()", + severity: "error", + async check(ctx) { + const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); + // Detect .option() calls whose description enumerates known choice values + // e.g. "editor integration to configure (claude, cursor, vscode, copilot)" + // or "ADR domain: backend, frontend, data, architecture, general" + const matches = await Promise.all( + files.map((file) => + ctx.grep( + file, + /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*(?:claude.*cursor|backend.*frontend)[^"']*["']/ + ) + ) + ); + for (const fileMatches of matches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + "Use new Option().choices() with .addOption() instead of .option() for fixed-choice options", + file: m.file, + line: m.line, + fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()", + }); + } + } + }, + }, + "use-add-option-for-arg-parser": { + description: + "Options with custom parsers must use addOption with argParser() instead of passing a parser to option()", + severity: "error", + async check(ctx) { + const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); + // Detect .option() calls that pass a function as the third argument + // e.g. .option("--max-entries ", "...", parseInt) + const matches = await Promise.all( + files.map((file) => + ctx.grep( + file, + /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*["'],\s*(?:parseInt|parseFloat|\()/ + ) + ) + ); + for (const fileMatches of matches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + "Use new Option().argParser() with .addOption() instead of passing a parser to .option()", + file: m.file, + line: m.line, + fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()", + }); + } + } + }, + }, +}); diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index 587515e9..ebcce6b3 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -56,6 +56,20 @@ If the `claude` CLI is on your PATH, the plugin is installed automatically via: If the `claude` CLI is not found, the command prints the manual commands for you to run. +### Installing the plugin on an existing project + +If your project is already initialized, you can install or reinstall the plugin without re-running `archgate init`: + +```bash +archgate plugin install +``` + +To get the authenticated repository URL for manual configuration: + +```bash +archgate plugin url +``` + ## Initial setup with onboard After installation, run the `archgate:onboard` skill in your project once. This skill: diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index 70599561..951483e2 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -47,6 +47,12 @@ To explicitly request plugin installation: archgate init --editor copilot --install-plugin ``` +To install or reinstall the plugin on an already-initialized project: + +```bash +archgate plugin install --editor copilot +``` + ### Generated files The command creates the following configuration: diff --git a/docs/src/content/docs/guides/cursor-integration.mdx b/docs/src/content/docs/guides/cursor-integration.mdx index 1afd62e5..8695f9ce 100644 --- a/docs/src/content/docs/guides/cursor-integration.mdx +++ b/docs/src/content/docs/guides/cursor-integration.mdx @@ -30,6 +30,12 @@ archgate init --editor cursor --install-plugin The plugin bundle is downloaded from `plugins.archgate.dev` and extracted into the `.cursor/` directory. It includes agent rules, skill definitions, and MCP configuration. +To install or reinstall the plugin on an already-initialized project: + +```bash +archgate plugin install --editor cursor +``` + ### Without plugin (free) Without the plugin, `archgate init --editor cursor` still configures the MCP server connection and a basic governance rule. The AI agent can access your ADRs and run checks, but does not get the role-based skills described below. diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index 197034af..77ec7186 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -59,6 +59,12 @@ To explicitly request plugin installation: archgate init --editor vscode --install-plugin ``` +To install or reinstall the plugin on an already-initialized project: + +```bash +archgate plugin install --editor vscode +``` + ### Generated files The command creates or updates the following files: diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 54b2fd5e..d92cd83e 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -56,6 +56,20 @@ Se o CLI `claude` estiver no seu PATH, o plugin é instalado automaticamente via Se o CLI `claude` não for encontrado, o comando exibe os comandos manuais para você executar. +### Instalando o plugin em um projeto existente + +Se seu projeto já foi inicializado, você pode instalar ou reinstalar o plugin sem executar `archgate init` novamente: + +```bash +archgate plugin install +``` + +Para obter a URL autenticada do repositório para configuração manual: + +```bash +archgate plugin url +``` + ## Configuração inicial com onboard Após a instalação, execute a skill `archgate:onboard` no seu projeto uma vez. Essa skill: 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 index 4d67cf1b..a1293989 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -47,6 +47,12 @@ Para solicitar explicitamente a instalação do plugin: archgate init --editor copilot --install-plugin ``` +Para instalar ou reinstalar o plugin em um projeto já inicializado: + +```bash +archgate plugin install --editor copilot +``` + ### Arquivos gerados O comando cria a seguinte configuração: diff --git a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx index 1225a4a9..987d8b17 100644 --- a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx @@ -30,6 +30,12 @@ archgate init --editor cursor --install-plugin O pacote do plugin é baixado de `plugins.archgate.dev` e extraído no diretório `.cursor/`. Ele inclui regras de agente, definições de skills e configuração MCP. +Para instalar ou reinstalar o plugin em um projeto já inicializado: + +```bash +archgate plugin install --editor cursor +``` + ### Sem plugin (gratuito) Sem o plugin, `archgate init --editor cursor` ainda configura a conexão com o servidor MCP e uma regra de governança básica. O agente de IA pode acessar seus ADRs e executar verificações, mas não recebe as skills baseadas em papéis descritas abaixo. diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index 0b0eaa92..54881134 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -59,6 +59,12 @@ Para solicitar explicitamente a instalação do plugin: archgate init --editor vscode --install-plugin ``` +Para instalar ou reinstalar o plugin em um projeto já inicializado: + +```bash +archgate plugin install --editor vscode +``` + ### Arquivos gerados O comando cria ou atualiza os seguintes arquivos: diff --git a/docs/src/content/docs/pt-br/reference/cli-commands.mdx b/docs/src/content/docs/pt-br/reference/cli-commands.mdx index 0db6b86a..d50fd2f7 100644 --- a/docs/src/content/docs/pt-br/reference/cli-commands.mdx +++ b/docs/src/content/docs/pt-br/reference/cli-commands.mdx @@ -102,10 +102,10 @@ Cria o diretório `.archgate/` com um ADR de exemplo, arquivo de regras compleme ### Opções -| Opção | Padrão | Descrição | -| ------------------- | -------- | ------------------------------------------------------------------------ | -| `--editor ` | `claude` | Integração de editor a configurar (`claude`, `cursor`) | -| `--install-plugin` | auto | Instalar o plugin de editor do Archgate (requer `archgate login` prévio) | +| Opção | Padrão | Descrição | +| ------------------- | -------- | --------------------------------------------------------------------------- | +| `--editor ` | `claude` | Integração de editor a configurar (`claude`, `cursor`, `vscode`, `copilot`) | +| `--install-plugin` | auto | Instalar o plugin de editor do Archgate (requer `archgate login` prévio) | Quando `--install-plugin` é passado, a CLI instala o plugin do Archgate para o editor selecionado. Se a flag for omitida, a CLI faz detecção automática: instala o plugin quando existem credenciais válidas (de um `archgate login` anterior) e pula caso contrário. @@ -141,6 +141,84 @@ Quando `--editor cursor` é usado, a saída mostra `.cursor/` em vez de `.claude --- +## archgate plugin + +Gerencia os plugins de editor do Archgate independentemente do `archgate init`. + +```bash +archgate plugin [options] +``` + +Use `archgate plugin` para instalar plugins ou obter a URL autenticada do repositório em projetos que já foram inicializados. + +### Subcomandos + +#### archgate plugin url + +Exibe a URL autenticada do repositório de plugins para configuração manual de ferramentas. + +```bash +archgate plugin url [options] +``` + +| Opção | Padrão | Descrição | +| ------------------- | -------- | ----------------------------------------------------- | +| `--editor ` | `claude` | Editor alvo (`claude`, `cursor`, `vscode`, `copilot`) | + +A URL inclui suas credenciais e pode ser usada para configurar manualmente as ferramentas do editor. Por exemplo, para adicionar o marketplace do Archgate no Claude Code: + +```bash +claude plugin marketplace add "$(archgate plugin url)" +claude plugin install archgate@archgate +``` + +Para o VS Code, a URL aponta para um repositório de plugin separado: + +```bash +archgate plugin url --editor vscode +``` + +#### archgate plugin install + +Instala o plugin do Archgate para o editor especificado em um projeto já inicializado. + +```bash +archgate plugin install [options] +``` + +| Opção | Padrão | Descrição | +| ------------------- | -------- | ----------------------------------------------------- | +| `--editor ` | `claude` | Editor alvo (`claude`, `cursor`, `vscode`, `copilot`) | + +O comportamento de instalação varia por editor: + +- **Claude Code:** Instala automaticamente via CLI `claude` se disponível; exibe comandos manuais caso contrário. +- **Copilot CLI:** Instala automaticamente via CLI `copilot` se disponível; exibe comandos manuais caso contrário. +- **Cursor:** Baixa e extrai o pacote do plugin no diretório `.cursor/`. +- **VS Code:** Adiciona a URL do marketplace nas configurações de usuário do VS Code. + +### Exemplos + +Obter a URL do plugin para configuração manual: + +```bash +archgate plugin url +``` + +Instalar o plugin para o Claude Code: + +```bash +archgate plugin install +``` + +Instalar o plugin para o Cursor: + +```bash +archgate plugin install --editor cursor +``` + +--- + ## archgate check Executa todas as verificações automatizadas de conformidade com ADRs no codebase. diff --git a/docs/src/content/docs/reference/cli-commands.mdx b/docs/src/content/docs/reference/cli-commands.mdx index 05d94467..d363ea5f 100644 --- a/docs/src/content/docs/reference/cli-commands.mdx +++ b/docs/src/content/docs/reference/cli-commands.mdx @@ -102,10 +102,10 @@ Creates the `.archgate/` directory with an example ADR, companion rules file, an ### Options -| Option | Default | Description | -| ------------------- | -------- | -------------------------------------------------------------------- | -| `--editor ` | `claude` | Editor integration to configure (`claude`, `cursor`) | -| `--install-plugin` | auto | Install the Archgate editor plugin (requires prior `archgate login`) | +| Option | Default | Description | +| ------------------- | -------- | ------------------------------------------------------------------------- | +| `--editor ` | `claude` | Editor integration to configure (`claude`, `cursor`, `vscode`, `copilot`) | +| `--install-plugin` | auto | Install the Archgate editor plugin (requires prior `archgate login`) | When `--install-plugin` is passed, the CLI installs the Archgate plugin for the selected editor. If the flag is omitted, the CLI auto-detects: it installs the plugin when valid credentials exist (from a previous `archgate login`) and skips otherwise. @@ -141,6 +141,84 @@ When `--editor cursor` is used, the output shows `.cursor/` instead of `.claude/ --- +## archgate plugin + +Manage Archgate editor plugins independently of `archgate init`. + +```bash +archgate plugin [options] +``` + +Use `archgate plugin` to install plugins or retrieve the authenticated repository URL on projects that have already been initialized. + +### Subcommands + +#### archgate plugin url + +Print the authenticated plugin repository URL for manual tool configuration. + +```bash +archgate plugin url [options] +``` + +| Option | Default | Description | +| ------------------- | -------- | ------------------------------------------------------- | +| `--editor ` | `claude` | Target editor (`claude`, `cursor`, `vscode`, `copilot`) | + +The URL includes your credentials and can be used to manually configure editor tools. For example, to add the Archgate marketplace in Claude Code: + +```bash +claude plugin marketplace add "$(archgate plugin url)" +claude plugin install archgate@archgate +``` + +For VS Code, the URL points to a separate plugin repository: + +```bash +archgate plugin url --editor vscode +``` + +#### archgate plugin install + +Install the Archgate plugin for the specified editor on an already-initialized project. + +```bash +archgate plugin install [options] +``` + +| Option | Default | Description | +| ------------------- | -------- | ------------------------------------------------------- | +| `--editor ` | `claude` | Target editor (`claude`, `cursor`, `vscode`, `copilot`) | + +Installation behavior varies by editor: + +- **Claude Code:** Auto-installs via `claude` CLI if available; prints manual commands otherwise. +- **Copilot CLI:** Auto-installs via `copilot` CLI if available; prints manual commands otherwise. +- **Cursor:** Downloads and extracts the plugin bundle into `.cursor/`. +- **VS Code:** Adds the marketplace URL to VS Code user settings. + +### Examples + +Get the plugin URL for manual configuration: + +```bash +archgate plugin url +``` + +Install the plugin for Claude Code: + +```bash +archgate plugin install +``` + +Install the plugin for Cursor: + +```bash +archgate plugin install --editor cursor +``` + +--- + ## archgate check Run all automated ADR compliance checks against the codebase. diff --git a/src/cli.ts b/src/cli.ts index 44938ddf..5353bcc3 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { registerCheckCommand } from "./commands/check"; import { registerLoginCommand } from "./commands/login"; import { registerReviewContextCommand } from "./commands/review-context"; import { registerSessionContextCommand } from "./commands/session-context/index"; +import { registerPluginCommand } from "./commands/plugin/index"; import { checkForUpdatesIfNeeded } from "./helpers/update-check"; import { logError } from "./helpers/log"; @@ -42,6 +43,7 @@ async function main() { registerCheckCommand(program); registerReviewContextCommand(program); registerSessionContextCommand(program); + registerPluginCommand(program); registerUpgradeCommand(program); registerCleanCommand(program); diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index 0aa94606..014ec596 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -1,24 +1,22 @@ import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; import { existsSync } from "node:fs"; import inquirer from "inquirer"; import { projectPaths } from "../../helpers/paths"; -import { - ADR_DOMAINS, - AdrFrontmatterSchema, - type AdrDomain, -} from "../../formats/adr"; +import { ADR_DOMAINS, type AdrDomain } from "../../formats/adr"; import { createAdrFile } from "../../helpers/adr-writer"; import { logError } from "../../helpers/log"; +const domainOption = new Option("--domain ", "ADR domain").choices( + ADR_DOMAINS +); + export function registerAdrCreateCommand(adr: Command) { adr .command("create") .description("Create a new ADR") .option("--title ", "ADR title (skip interactive prompt)") - .option( - "--domain <domain>", - "ADR domain: backend, frontend, data, architecture, general" - ) + .addOption(domainOption) .option("--files <patterns>", "File patterns, comma-separated") .option("--body <markdown>", "Full ADR body markdown (skip template)") .option("--rules", "Set rules: true in frontmatter") @@ -39,16 +37,7 @@ export function registerAdrCreateCommand(adr: Command) { // Non-interactive mode when --title and --domain are provided if (opts.title && opts.domain) { - const domainResult = AdrFrontmatterSchema.shape.domain.safeParse( - opts.domain - ); - if (!domainResult.success) { - logError( - `Invalid domain '${opts.domain}'. Must be one of: ${ADR_DOMAINS.join(", ")}` - ); - process.exit(1); - } - domain = domainResult.data; + domain = opts.domain; title = opts.title; files = opts.files ? opts.files diff --git a/src/commands/adr/update.ts b/src/commands/adr/update.ts index af4ed910..3376cb6b 100644 --- a/src/commands/adr/update.ts +++ b/src/commands/adr/update.ts @@ -1,14 +1,15 @@ import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; import { existsSync } from "node:fs"; import { projectPaths } from "../../helpers/paths"; -import { - ADR_DOMAINS, - AdrFrontmatterSchema, - type AdrDomain, -} from "../../formats/adr"; +import { ADR_DOMAINS } from "../../formats/adr"; import { updateAdrFile } from "../../helpers/adr-writer"; import { logError } from "../../helpers/log"; +const domainOption = new Option("--domain <domain>", "new ADR domain").choices( + ADR_DOMAINS +); + export function registerAdrUpdateCommand(adr: Command) { adr .command("update") @@ -16,10 +17,7 @@ export function registerAdrUpdateCommand(adr: Command) { .requiredOption("--id <id>", "ADR ID to update (e.g., ARCH-001)") .requiredOption("--body <markdown>", "Full replacement ADR body markdown") .option("--title <title>", "New ADR title (preserves existing if omitted)") - .option( - "--domain <domain>", - "New domain: backend, frontend, data, architecture, general" - ) + .addOption(domainOption) .option( "--files <patterns>", "New file patterns, comma-separated (preserves existing if omitted)" @@ -35,21 +33,6 @@ export function registerAdrUpdateCommand(adr: Command) { process.exit(1); } - let domain: AdrDomain | undefined; - - if (opts.domain) { - const domainResult = AdrFrontmatterSchema.shape.domain.safeParse( - opts.domain - ); - if (!domainResult.success) { - logError( - `Invalid domain '${opts.domain}'. Must be one of: ${ADR_DOMAINS.join(", ")}` - ); - process.exit(1); - } - domain = domainResult.data; - } - const files = opts.files ? opts.files .split(",") @@ -62,7 +45,7 @@ export function registerAdrUpdateCommand(adr: Command) { id: opts.id, body: opts.body, title: opts.title, - domain, + domain: opts.domain, files, rules: opts.rules, }); diff --git a/src/commands/init.ts b/src/commands/init.ts index 50bfce12..b341b02a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,12 +1,11 @@ import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; import { styleText } from "node:util"; import { logError, logInfo, logWarn } from "../helpers/log"; import { initProject } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; import { loadCredentials } from "../helpers/auth"; -const VALID_EDITORS = ["claude", "cursor", "vscode", "copilot"] as const; - const EDITOR_LABELS: Record<EditorTarget, string> = { claude: "Claude Code", cursor: "Cursor", @@ -21,41 +20,32 @@ const EDITOR_DIRS: Record<EditorTarget, string> = { copilot: ".github/copilot/", }; +const editorOption = new Option("--editor <editor>", "editor integration") + .choices(["claude", "cursor", "vscode", "copilot"] as const) + .default("claude" as const); + export function registerInitCommand(program: Command) { program .command("init") .description("Initialize Archgate governance in the current project") - .option( - "--editor <editor>", - "editor integration to configure (claude, cursor, vscode, copilot)", - "claude" - ) + .addOption(editorOption) .option( "--install-plugin", "install the archgate plugin (requires prior `archgate login`)" ) .action(async (opts) => { try { - const editor = opts.editor as string; - if (!VALID_EDITORS.includes(editor as EditorTarget)) { - logError( - `Unknown editor "${editor}". Supported: ${VALID_EDITORS.join(", ")}` - ); - process.exit(1); - } - // Auto-detect: install plugin if credentials exist (unless explicitly off) const installPlugin = opts.installPlugin ?? (await loadCredentials()) !== null; const result = await initProject(process.cwd(), { - editor: editor as EditorTarget, + editor: opts.editor, installPlugin, }); - const editorTarget = editor as EditorTarget; - const label = EDITOR_LABELS[editorTarget]; - const dir = EDITOR_DIRS[editorTarget]; + const label = EDITOR_LABELS[opts.editor]; + const dir = EDITOR_DIRS[opts.editor]; console.log(`Initialized Archgate governance in ${result.projectRoot}`); console.log(` adrs/ - architecture decision records`); @@ -72,7 +62,7 @@ export function registerInitCommand(program: Command) { } } else { // CLI not found for this editor — show manual commands - printManualInstructions(editorTarget, result.plugin.detail); + printManualInstructions(opts.editor, result.plugin.detail); } } else if (installPlugin) { // User wanted plugin but no credentials diff --git a/src/commands/plugin/index.ts b/src/commands/plugin/index.ts new file mode 100644 index 00000000..a807289e --- /dev/null +++ b/src/commands/plugin/index.ts @@ -0,0 +1,12 @@ +import type { Command } from "@commander-js/extra-typings"; +import { registerPluginUrlCommand } from "./url"; +import { registerPluginInstallCommand } from "./install"; + +export function registerPluginCommand(program: Command) { + const plugin = program + .command("plugin") + .description("Manage archgate editor plugins"); + + registerPluginUrlCommand(plugin); + registerPluginInstallCommand(plugin); +} diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts new file mode 100644 index 00000000..2cd596bd --- /dev/null +++ b/src/commands/plugin/install.ts @@ -0,0 +1,113 @@ +import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; +import { styleText } from "node:util"; +import { loadCredentials } from "../../helpers/auth"; +import { + buildMarketplaceUrl, + buildVscodeMarketplaceUrl, + installClaudePlugin, + installCopilotPlugin, + installCursorPlugin, + isClaudeCliAvailable, + isCopilotCliAvailable, +} from "../../helpers/plugin-install"; +import { configureVscodeSettings } from "../../helpers/vscode-settings"; +import { logError, logInfo, logWarn } from "../../helpers/log"; +import type { EditorTarget } from "../../helpers/init-project"; + +const EDITOR_LABELS: Record<EditorTarget, string> = { + claude: "Claude Code", + cursor: "Cursor", + vscode: "VS Code", + copilot: "Copilot CLI", +}; + +const editorOption = new Option("--editor <editor>", "target editor") + .choices(["claude", "cursor", "vscode", "copilot"] as const) + .default("claude" as const); + +export function registerPluginInstallCommand(plugin: Command) { + plugin + .command("install") + .description("Install the archgate plugin for the specified editor") + .addOption(editorOption) + .action(async (opts) => { + const credentials = await loadCredentials(); + if (!credentials) { + logError( + "Not logged in.", + "Run `archgate login` first to authenticate." + ); + process.exit(1); + } + + const label = EDITOR_LABELS[opts.editor]; + + try { + switch (opts.editor) { + case "claude": { + if (await isClaudeCliAvailable()) { + await installClaudePlugin(credentials); + logInfo(`Archgate plugin installed for ${label}.`); + } else { + const url = buildMarketplaceUrl(credentials); + logWarn( + "Claude CLI not found. To install the plugin manually, run:" + ); + console.log( + ` ${styleText("bold", "claude plugin marketplace add")} ${url}` + ); + console.log( + ` ${styleText("bold", "claude plugin install")} archgate@archgate` + ); + } + break; + } + + case "copilot": { + if (await isCopilotCliAvailable()) { + await installCopilotPlugin(credentials); + logInfo(`Archgate plugin installed for ${label}.`); + } else { + const url = buildMarketplaceUrl(credentials); + logWarn( + "Copilot CLI not found. To install the plugin manually, run:" + ); + console.log( + ` ${styleText("bold", "copilot plugin install")} ${url}` + ); + } + break; + } + + case "cursor": { + const files = await installCursorPlugin( + process.cwd(), + credentials.token + ); + logInfo( + `Archgate plugin installed for ${label}.`, + `Extracted ${files.length} files to .cursor/` + ); + break; + } + + case "vscode": { + const url = buildVscodeMarketplaceUrl(credentials); + await configureVscodeSettings(process.cwd(), url); + logInfo( + `Archgate plugin configured for ${label}.`, + "Marketplace URL added to VS Code user settings." + ); + break; + } + } + } catch (err) { + logError( + `Failed to install plugin for ${label}.`, + err instanceof Error ? err.message : String(err) + ); + process.exit(1); + } + }); +} diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts new file mode 100644 index 00000000..7b439141 --- /dev/null +++ b/src/commands/plugin/url.ts @@ -0,0 +1,38 @@ +import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; +import { loadCredentials } from "../../helpers/auth"; +import { + buildMarketplaceUrl, + buildVscodeMarketplaceUrl, +} from "../../helpers/plugin-install"; +import { logError } from "../../helpers/log"; + +const editorOption = new Option("--editor <editor>", "target editor") + .choices(["claude", "cursor", "vscode", "copilot"] as const) + .default("claude" as const); + +export function registerPluginUrlCommand(plugin: Command) { + plugin + .command("url") + .description( + "Print the authenticated plugin repository URL for manual configuration" + ) + .addOption(editorOption) + .action(async (opts) => { + const credentials = await loadCredentials(); + if (!credentials) { + logError( + "Not logged in.", + "Run `archgate login` first to authenticate." + ); + process.exit(1); + } + + const url = + opts.editor === "vscode" + ? buildVscodeMarketplaceUrl(credentials) + : buildMarketplaceUrl(credentials); + + console.log(url); + }); +} diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index 26b932b0..36c7e7cc 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -1,8 +1,14 @@ import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; import { logError } from "../helpers/log"; import { findProjectRoot } from "../helpers/paths"; import { buildReviewContext } from "../engine/context"; -import { AdrFrontmatterSchema } from "../formats/adr"; +import { ADR_DOMAINS } from "../formats/adr"; + +const domainOption = new Option( + "--domain <domain>", + "filter to a single domain" +).choices(ADR_DOMAINS); export function registerReviewContextCommand(program: Command) { program @@ -12,10 +18,7 @@ export function registerReviewContextCommand(program: Command) { ) .option("--staged", "Only include git-staged files") .option("--run-checks", "Include ADR compliance check results") - .option( - "--domain <domain>", - "Filter to a single domain (backend, frontend, data, architecture, general)" - ) + .addOption(domainOption) .action(async (opts) => { const projectRoot = findProjectRoot(); if (!projectRoot) { @@ -25,26 +28,10 @@ export function registerReviewContextCommand(program: Command) { process.exit(1); } - if (opts.domain) { - const result = AdrFrontmatterSchema.shape.domain.safeParse(opts.domain); - if (!result.success) { - logError( - `Invalid domain '${opts.domain}'. Use: backend, frontend, data, architecture, general` - ); - process.exit(1); - } - } - const context = await buildReviewContext(projectRoot, { staged: opts.staged, runChecks: opts.runChecks, - domain: opts.domain as - | "backend" - | "frontend" - | "data" - | "architecture" - | "general" - | undefined, + domain: opts.domain, }); console.log(JSON.stringify(context, null, 2)); diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index 433aa186..41a0d724 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -1,17 +1,19 @@ import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; import { findProjectRoot } from "../../helpers/paths"; import { readClaudeCodeSession } from "../../helpers/session-context"; import { logError } from "../../helpers/log"; +const maxEntriesOption = new Option( + "--max-entries <n>", + "maximum entries to return (default: 200)" +).argParser((val) => parseInt(val, 10)); + export function registerClaudeCodeSessionContextCommand(parent: Command) { parent .command("claude-code") .description("Read Claude Code session transcript for the project") - .option( - "--max-entries <n>", - "Maximum entries to return (default: 200)", - parseInt - ) + .addOption(maxEntriesOption) .action(async (opts) => { const projectRoot = findProjectRoot(); const result = await readClaudeCodeSession(projectRoot, { diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 35d934ed..32e7e6dc 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -1,17 +1,19 @@ import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; import { findProjectRoot } from "../../helpers/paths"; import { readCursorSession } from "../../helpers/session-context"; import { logError } from "../../helpers/log"; +const maxEntriesOption = new Option( + "--max-entries <n>", + "maximum entries to return (default: 200)" +).argParser((val) => parseInt(val, 10)); + export function registerCursorSessionContextCommand(parent: Command) { parent .command("cursor") .description("Read Cursor agent session transcript for the project") - .option( - "--max-entries <n>", - "Maximum entries to return (default: 200)", - parseInt - ) + .addOption(maxEntriesOption) .option("--session-id <id>", "Specific session UUID to read") .action(async (opts) => { const projectRoot = findProjectRoot(); diff --git a/tests/commands/plugin/install.test.ts b/tests/commands/plugin/install.test.ts new file mode 100644 index 00000000..635ed1bc --- /dev/null +++ b/tests/commands/plugin/install.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { Command } from "@commander-js/extra-typings"; +import { registerPluginInstallCommand } from "../../../src/commands/plugin/install"; + +describe("registerPluginInstallCommand", () => { + test("registers 'install' as a subcommand", () => { + const program = new Command(); + registerPluginInstallCommand(program); + const sub = program.commands.find((c) => c.name() === "install"); + expect(sub).toBeDefined(); + }); + + test("has a description", () => { + const program = new Command(); + registerPluginInstallCommand(program); + const sub = program.commands.find((c) => c.name() === "install")!; + expect(sub.description()).toBeTruthy(); + }); + + test("accepts --editor option with default 'claude'", () => { + const program = new Command(); + registerPluginInstallCommand(program); + const sub = program.commands.find((c) => c.name() === "install")!; + const editorOpt = sub.options.find((o) => o.long === "--editor"); + expect(editorOpt).toBeDefined(); + expect(editorOpt!.defaultValue).toBe("claude"); + }); + + test("--editor option restricts choices to valid editors", () => { + const program = new Command(); + registerPluginInstallCommand(program); + const sub = program.commands.find((c) => c.name() === "install")!; + const editorOpt = sub.options.find((o) => o.long === "--editor")!; + expect(editorOpt.argChoices).toEqual([ + "claude", + "cursor", + "vscode", + "copilot", + ]); + }); +}); diff --git a/tests/commands/plugin/url.test.ts b/tests/commands/plugin/url.test.ts new file mode 100644 index 00000000..527eaa03 --- /dev/null +++ b/tests/commands/plugin/url.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { Command } from "@commander-js/extra-typings"; +import { registerPluginUrlCommand } from "../../../src/commands/plugin/url"; + +describe("registerPluginUrlCommand", () => { + test("registers 'url' as a subcommand", () => { + const program = new Command(); + registerPluginUrlCommand(program); + const sub = program.commands.find((c) => c.name() === "url"); + expect(sub).toBeDefined(); + }); + + test("has a description", () => { + const program = new Command(); + registerPluginUrlCommand(program); + const sub = program.commands.find((c) => c.name() === "url")!; + expect(sub.description()).toBeTruthy(); + }); + + test("accepts --editor option with default 'claude'", () => { + const program = new Command(); + registerPluginUrlCommand(program); + const sub = program.commands.find((c) => c.name() === "url")!; + const editorOpt = sub.options.find((o) => o.long === "--editor"); + expect(editorOpt).toBeDefined(); + expect(editorOpt!.defaultValue).toBe("claude"); + }); + + test("--editor option restricts choices to valid editors", () => { + const program = new Command(); + registerPluginUrlCommand(program); + const sub = program.commands.find((c) => c.name() === "url")!; + const editorOpt = sub.options.find((o) => o.long === "--editor")!; + expect(editorOpt.argChoices).toEqual([ + "claude", + "cursor", + "vscode", + "copilot", + ]); + }); +});