Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .archgate/adrs/ARCH-008-typed-command-options.md
Original file line number Diff line number Diff line change
@@ -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 <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 <n>",
"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 <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 <n>", "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
63 changes: 63 additions & 0 deletions .archgate/adrs/ARCH-008-typed-command-options.rules.ts
Original file line number Diff line number Diff line change
@@ -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 <n>", "...", 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()",
});
}
}
},
},
});
14 changes: 14 additions & 0 deletions docs/src/content/docs/guides/claude-code-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/guides/copilot-cli-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/guides/cursor-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/guides/vscode-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/pt-br/guides/cursor-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/pt-br/guides/vscode-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading