ADR-005: Command-Handler Contract¶
Status¶
Accepted — in progress (Wave 4)
Context¶
Each mcc <command> subcommand (bootstrap, architecture, import, update,
regenerate, prove, mcp, secrets) is implemented as a class in
src/lib/<command>-command-handler.js, lazily imported and invoked by
bin/cli.js. The eight handlers grew independently and share a loose shape but
have no common base:
- All are
export default class XCommandHandlerwith anasync handle(...). - Three of them (import, update, regenerate) copy-paste the identical module
header block computing
GENERATOR_ROOT/TEMPLATE_DIR: - The
handlesignatures legitimately diverge:importtakeshandle(endpointArn),update/regeneratetakehandle(), bootstrap/mcp/prove/secrets/architecture takehandle(args, options). - Error/exit conventions inconsistently split: some handlers hard-exit
(
process.exit(1)), others setprocess.exitCode = 1and continue.
For an AI maintainer, "8 classes that are almost the same but not quite" is hard
to reason about: which parts are essential vs incidental? The GENERATOR_ROOT
block is pure incidental duplication; the handle variance is essential.
Decision¶
Introduce a lightweight BaseCommandHandler (src/lib/base-command-handler.js)
that captures only what is genuinely shared, and explicitly document what is
intentionally NOT uniform.
What the base provides¶
GENERATOR_ROOT/TEMPLATE_DIR— computed once in the base (all handlers live insrc/lib/, so the paths are identical) and exposed both as instance properties (this.GENERATOR_ROOT) and as named exports for handlers that use them as module-level constants. This deletes the copy-pasted block.fail(message, { exit = true })— the standard failure convention: print and stop.exit: true(default) hard-exits (matching import/update/regenerate fatal failures);exit: falsesetsprocess.exitCode(for handlers that do cleanup after a soft failure, like prove/secrets). This gives the two existing error styles a single, named, documented home without forcing a rewrite.handle()— an abstract method that throws"<Handler> must implement async handle(...)"if a subclass forgets to override it. The contract is "there is an asynchandle," not a fixed signature.
What is intentionally NOT uniform (documented divergence)¶
handle(...)signature. Each command's inputs differ by nature. Forcing a single(args, options)shape would add a translation layer with no benefit.bin/cli.jsalready knows each command's shape at its call site. The base's abstracthandle()documents the existence of the method, not its arity.- Constructor options. Each handler takes its own
{ ... }options bag ({ dryRun, region }for import;{ dryRun, force, noRegister, allTargets }for regenerate; …). These are command-specific and stay per-handler; the only rule is callingsuper().
Scope¶
Wave 4 migrates the three handlers that shared the GENERATOR_ROOT block —
import, update, regenerate — onto BaseCommandHandler (extend + super(),
import the shared paths). The other five (bootstrap, architecture, mcp, prove,
secrets) may adopt it incrementally; they are not forced to, because they do not
share the path block and their divergence is legitimate. A test asserts the
migrated handlers extend the base and expose handle.
Consequences¶
- Positive: the copy-pasted
GENERATOR_ROOT/TEMPLATE_DIRblock is gone (one source of truth); error/exit handling has a named convention; the "is there a handle()?" contract is enforced. An AI reading a handler seesextends BaseCommandHandlerand knows exactly what is shared. - Cost: three handlers change (behavior-preserving);
bin/cli.jsdispatch is unchanged (it still doesnew Handler(opts).handle(...)). - Risk: low — the base only adds shared paths + a fail helper + an abstract
method; it does not intercept
handle. The divergent signatures are preserved exactly, so no call site changes.
References¶
src/lib/base-command-handler.js— the base classdocs/architecture/command-handlers.md— the handler map + which extend the basesrc/lib/do-config.js— the shared do/config parser (Wave 4 Task 1)- ADR-002 — the consolidation program