import type { CommandSpec } from "../internals/plan.js"; /** * The pluggable command registry — aih's ONE extension seam (OPA/Semgrep-style * open core). The public harness is complete and fully local on its own; on * startup the CLI probes for a single optional peer package, * {@link PLUGIN_PACKAGE}, and when it is installed and valid, the `aihCommands` * CommandSpecs it exports merge into the registry and appear as NATIVE * subcommands — flowing through the identical registration path as the * built-ins (shared flags, posture resolution, dirty-worktree gate, run * ledger), so the private package bolts on without forking the core. An * unenrolled machine sees zero output and zero behavior change. * * Rules the seam is built on: * - LITERAL package name only. The probe always imports {@link PLUGIN_PACKAGE} * verbatim — never an env var, flag, or config value — so nothing * user-controlled can point the import at other code. (The `importer` and * `resolver` options are purely test seams; production uses the platform * dynamic import and `import.meta.resolve`.) * - SAME INSTALL TREE only. Before importing, the probe resolves where the * import WOULD load from and refuses anything outside the harness's own * install tree, so a global or `npx`-run aih pointed at a hostile repo can * never import that repo's planted `node_modules/@aihq/enterprise`. Honesty * note (also in the README): when aih itself is installed INSIDE the target * repo, the repo already controls the binary — the check draws the boundary * at "the tree aih runs from", nothing stronger. * - STARTUP BUDGET. The import races a timeout (default * {@link DEFAULT_IMPORT_TIMEOUT_MS} ms); a slow or wedged plugin degrades to * local-only with a warning instead of stalling every invocation. (`aih * --version` skips the probe entirely — see src/cli.ts.) * - Kill switch: `AIH_NO_PLUGINS=1` (read from the injectable `env`) skips the * probe without touching the importer at all. * - Fail open to LOCAL. A missing package is the normal unenrolled case * (silent — zero noise); anything else (the package present but failing to * resolve or load, a malformed export, an invalid or colliding spec) * degrades to local-only behavior with a one-line warning. A broken plugin * must never break the CLI. * - Built-ins always win: a plugin spec whose name collides with a built-in * command, a parent group, or commander's own `help`/`version` is refused * ("refusing to shadow"). * - Shared + reserved flags are off-limits: a plugin option may not claim any * token from {@link SHARED_FLAG_TOKENS} (the addSharedFlags surface), the * same Commander option attribute by alternate spelling, or commander's * reserved `--help`/`-h`/`--version`/`-V`. * - `skipWorktreeGate` is never honored for plugin commands — the field is * stripped from the registered copy (see {@link stripWorktreeGateField}). * - `aliases` / `deprecatedAliases` are never honored for plugin commands — * aliases are core-owned invocation names, and an alias is an extra dispatch * name the collision rules above do not walk. The fields are stripped from * the registered copy with a warning (see {@link stripAliasesField} / * {@link stripDeprecatedAliasesField}); built-in aliases stay reserved * against plugin NAMES via builtinCommandNames. * - Warnings render hostile input: every plugin-influenced string that lands * in a warning routes through {@link sanitizeLabel} first. * * Trust boundary note: the boundary is package INSTALLATION — by the time this * module inspects the export, `import()` has already run the plugin's module * code, exactly like any other installed dependency. The structural gate below * is about REGISTRY INTEGRITY (only well-formed, non-colliding specs register), * not sandboxing: a gated spec may use any {@link CommandSpec} field, including * `readOnly` (`skipWorktreeGate` being the one carve-out). */ /** The one probed plugin package. Literal by design — see the module jsdoc. */ export declare const PLUGIN_PACKAGE = "@aihq/enterprise"; export interface PluginLoadResult { commands: CommandSpec[]; warnings: string[]; } /** Import seam so tests can simulate any module shape without installing anything. */ export type PluginImporter = (specifier: string) => Promise; /** * Resolver seam for the install-tree boundary: maps the package specifier to * the FILE PATH the import would load from. Production uses * `import.meta.resolve`; tests inject paths inside/outside the allowed roots. */ export type PluginResolver = (specifier: string) => string; export interface PluginLoadOptions { /** Test seam replacing the platform dynamic import. */ importer?: PluginImporter; /** Test seam replacing `import.meta.resolve` for the install-tree check. */ resolver?: PluginResolver; /** Environment for the kill switch — matches runCapability's deps.env convention. */ env?: NodeJS.ProcessEnv; /** Import budget in milliseconds (default {@link DEFAULT_IMPORT_TIMEOUT_MS}). */ timeoutMs?: number; } /** * Long flag tokens `addSharedFlags` (src/commands/index.ts) puts on every * capability subcommand. Mirrored as a constant because the registry must stay * a leaf module — importing the command tree from here would create an import * cycle (commands/index.ts imports {@link sanitizeLabel} back from this file). * The mirror is pinned against the real addSharedFlags registration by * tests/plugins/registry.test.ts, so any drift fails CI. */ export declare const SHARED_FLAG_TOKENS: ReadonlySet; /** * Make a plugin-influenced string safe to echo in a one-line warning: collapse * newlines to spaces, strip C0/C1 control characters (including ESC, so * ANSI/OSC sequences lose their teeth) plus DEL, and truncate to `max` with an * ellipsis. Exported so the plugin-registration containment in * src/commands/index.ts routes through the SAME sanitizer — one * implementation, no drift. */ export declare function sanitizeLabel(value: string, max?: number): string; /** * Every root the plugin is allowed to resolve under: each `node_modules` * directory on the ancestor chain of THIS module's own file (after bundling, * that file IS the CLI binary in `dist/`), plus `/node_modules` * where the package root is the first ancestor directory carrying a * package.json — that second clause covers running from the dev tree, where no * ancestor is itself a node_modules. Everything is realpath'd; candidates that * do not exist are dropped (a missing directory cannot contain the plugin). * Exported as a test seam so tests can build paths inside a real root. */ export declare function allowedPluginRoots(): string[]; /** * Probe for {@link PLUGIN_PACKAGE} and return its registrable CommandSpecs. * Never throws: every failure mode degrades to `{ commands: [] }` plus at most * one-line warnings, so the CLI stays fully local no matter how broken the * plugin is. `AIH_NO_PLUGINS=1` (from `opts.env ?? process.env`) skips the * probe entirely. See {@link PluginLoadOptions} for the test seams. */ export declare function loadExternalCommands(builtinNames: ReadonlySet, opts?: PluginLoadOptions): Promise;