|
| 1 | +--- |
| 2 | +id: ARCH-001 |
| 3 | +title: Command Structure |
| 4 | +domain: architecture |
| 5 | +rules: true |
| 6 | +files: ["src/commands/**/*.ts"] |
| 7 | +--- |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +The CLI needs a consistent pattern for defining and registering commands. As the command surface grows (init, check, adr, mcp, upgrade, clean), the registration mechanism must scale without introducing hidden coupling or making the dependency graph opaque. |
| 12 | + |
| 13 | +**Alternatives considered:** |
| 14 | + |
| 15 | +- **Auto-discovery via `executableDir()`** — Commander.js supports automatic command discovery by scanning a directory for executable files. This eliminates manual imports but hides the dependency graph: adding or removing a command has no type-checked reference, making dead command detection impossible. It also requires each command to be a standalone executable, which prevents in-process testing and forces separate process spawning for every subcommand invocation. |
| 16 | +- **Plugin-based registration** — A plugin system where commands register themselves via a manifest or hook (similar to Oclif or Clipanion). This adds flexibility for third-party extensions but introduces significant complexity for an internal CLI with a known, finite set of commands. The indirection makes it harder to trace which code handles which command. |
| 17 | +- **Single-file command map** — Define all commands in a single file as a map of name-to-handler. Simple but creates a monolithic file that grows with every command, making merge conflicts frequent and readability poor. |
| 18 | + |
| 19 | +The explicit register pattern strikes the right balance: each command owns its registration logic, the entry point makes all commands visible at a glance, and in-process execution enables straightforward testing without process spawning. |
| 20 | + |
| 21 | +## Decision |
| 22 | + |
| 23 | +Commands live in `src/commands/` and export a `register*Command(program)` function. The main entry point (`src/cli.ts`) explicitly imports and calls each register function. Subcommands (e.g., `adr create`, `adr list`) use nested directories with an `index.ts` that composes the subcommand group. |
| 24 | + |
| 25 | +**Key constraints:** |
| 26 | + |
| 27 | +1. **One command per file** — Each `.ts` file in `src/commands/` defines exactly one command (or one command group via its `index.ts`) |
| 28 | +2. **Explicit registration** — Every command must be manually imported and registered in `src/cli.ts`. No auto-discovery. |
| 29 | +3. **Thin commands** — Command files handle I/O only: parse arguments, call engine/helpers, format output. No business logic. |
| 30 | +4. **In-process execution** — Commands run in the same Bun process as the CLI entry point. No child process spawning. |
| 31 | + |
| 32 | +## Do's and Don'ts |
| 33 | + |
| 34 | +### Do |
| 35 | + |
| 36 | +- Export a `register*Command` function from each command module |
| 37 | +- Keep commands thin: parse args, call helpers/engine, format output |
| 38 | +- Use `src/commands/<name>.ts` for top-level commands |
| 39 | +- Use `src/commands/<name>/index.ts` for command groups with subcommands |
| 40 | +- Import the register function explicitly in `src/cli.ts` |
| 41 | + |
| 42 | +### Don't |
| 43 | + |
| 44 | +- Don't put business logic in command files — move it to `src/engine/`, `src/helpers/`, or `src/formats/` |
| 45 | +- Don't use `executableDir()` for command discovery |
| 46 | +- Don't call `.parse()` in command files — the entry point handles parsing |
| 47 | +- Don't create commands that spawn child processes for subcommand execution |
| 48 | + |
| 49 | +## Implementation Pattern |
| 50 | + |
| 51 | +### Good Example |
| 52 | + |
| 53 | +```typescript |
| 54 | +// src/commands/check.ts — thin command that delegates to engine |
| 55 | +import type { Command } from "@commander-js/extra-typings"; |
| 56 | +import { loadRuleAdrs } from "../engine/loader"; |
| 57 | +import { runChecks } from "../engine/runner"; |
| 58 | +import { reportConsole, reportJSON, getExitCode } from "../engine/reporter"; |
| 59 | +import { logError } from "../helpers/log"; |
| 60 | + |
| 61 | +export function registerCheckCommand(program: Command) { |
| 62 | + program |
| 63 | + .command("check") |
| 64 | + .description("Run automated ADR compliance checks") |
| 65 | + .option("--json", "Output results as JSON") |
| 66 | + .option("--staged", "Only check staged files") |
| 67 | + .action(async (opts) => { |
| 68 | + const adrs = await loadRuleAdrs(); |
| 69 | + const results = await runChecks(adrs, { staged: opts.staged }); |
| 70 | + if (opts.json) { |
| 71 | + reportJSON(results); |
| 72 | + } else { |
| 73 | + reportConsole(results); |
| 74 | + } |
| 75 | + process.exit(getExitCode(results)); |
| 76 | + }); |
| 77 | +} |
| 78 | +``` |
| 79 | + |
| 80 | +```typescript |
| 81 | +// src/cli.ts — explicit imports make all commands visible |
| 82 | +import { registerCheckCommand } from "./commands/check"; |
| 83 | +import { registerInitCommand } from "./commands/init"; |
| 84 | +import { registerAdrCommand } from "./commands/adr"; |
| 85 | + |
| 86 | +registerInitCommand(program); |
| 87 | +registerCheckCommand(program); |
| 88 | +registerAdrCommand(program); |
| 89 | +``` |
| 90 | + |
| 91 | +### Bad Example |
| 92 | + |
| 93 | +```typescript |
| 94 | +// BAD: business logic inside command file |
| 95 | +export function registerCheckCommand(program: Command) { |
| 96 | + program.command("check").action(async () => { |
| 97 | + // Business logic should NOT be here |
| 98 | + const files = await glob("src/**/*.ts"); |
| 99 | + for (const file of files) { |
| 100 | + const content = await Bun.file(file).text(); |
| 101 | + const violations = content.match(/console\.error/g); |
| 102 | + // ... complex processing ... |
| 103 | + } |
| 104 | + }); |
| 105 | +} |
| 106 | +``` |
| 107 | + |
| 108 | +### Subcommand Group Pattern |
| 109 | + |
| 110 | +```typescript |
| 111 | +// src/commands/adr/index.ts — composes subcommand group (contains real logic) |
| 112 | +import type { Command } from "@commander-js/extra-typings"; |
| 113 | +import { registerAdrCreateCommand } from "./create"; |
| 114 | +import { registerAdrListCommand } from "./list"; |
| 115 | +import { registerAdrShowCommand } from "./show"; |
| 116 | +import { registerAdrUpdateCommand } from "./update"; |
| 117 | + |
| 118 | +export function registerAdrCommand(program: Command) { |
| 119 | + const adr = program |
| 120 | + .command("adr") |
| 121 | + .description("Manage Architecture Decision Records"); |
| 122 | + |
| 123 | + registerAdrCreateCommand(adr); |
| 124 | + registerAdrListCommand(adr); |
| 125 | + registerAdrShowCommand(adr); |
| 126 | + registerAdrUpdateCommand(adr); |
| 127 | +} |
| 128 | +``` |
| 129 | + |
| 130 | +## Consequences |
| 131 | + |
| 132 | +### Positive |
| 133 | + |
| 134 | +- **In-process execution enables testing** — Commands can be tested by calling `register*Command()` directly, without spawning subprocesses or mocking executables |
| 135 | +- **Explicit imports make dependencies clear** — Opening `src/cli.ts` shows every command the CLI supports. No hidden commands loaded at runtime. |
| 136 | +- **Subcommand nesting is straightforward** — Command groups use the same pattern as top-level commands, with an `index.ts` that composes children |
| 137 | +- **Type-safe registration** — Commander.js `@commander-js/extra-typings` provides full type inference for options and arguments within each register function |
| 138 | + |
| 139 | +### Negative |
| 140 | + |
| 141 | +- **Manual import bookkeeping** — Each new command requires adding an import and registration call in `src/cli.ts`. This is a minor overhead for a CLI with fewer than 15 commands. |
| 142 | +- **No hot-reload of commands** — Adding a new command requires restarting the CLI process. Acceptable for a development tool. |
| 143 | + |
| 144 | +### Risks |
| 145 | + |
| 146 | +- **Stale imports when commands are removed** — If a command file is deleted but its import in `src/cli.ts` is not removed, TypeScript will catch the error at compile time. The `bun run typecheck` step in the validation pipeline prevents this from reaching production. |
| 147 | +- **Command group index.ts confused with barrels** — The `index.ts` files in command group directories (e.g., `src/commands/adr/index.ts`) contain real composition logic, not re-exports. [ARCH-004 — No Barrel Files](./ARCH-004-no-barrel-files.md) explicitly permits `index.ts` files with logic. |
| 148 | + |
| 149 | +## Compliance and Enforcement |
| 150 | + |
| 151 | +### Automated Enforcement |
| 152 | + |
| 153 | +- **Archgate rule** `ARCH-001/register-function-export`: Scans all command files under `src/commands/` (excluding `index.ts` group files) and verifies each exports a `register*Command` function. Severity: `error`. |
| 154 | +- **Archgate rule** `ARCH-001/no-business-logic`: Detects complex data transformation patterns in command files that should be in helpers. Severity: `error`. |
| 155 | + |
| 156 | +### Manual Enforcement |
| 157 | + |
| 158 | +Code reviewers MUST verify: |
| 159 | + |
| 160 | +1. New commands are imported and registered in `src/cli.ts` |
| 161 | +2. Command files delegate to engine/helpers for business logic |
| 162 | +3. Command group `index.ts` files contain composition logic, not just re-exports |
| 163 | + |
| 164 | +## References |
| 165 | + |
| 166 | +- [Commander.js documentation](https://github.com/tj/commander.js) |
| 167 | +- [ARCH-004 — No Barrel Files](./ARCH-004-no-barrel-files.md) — Permits `index.ts` with logic, forbids re-export-only barrels |
0 commit comments