The tool-plugin model

The CLI is a generic dispatcher. It cannot tell fit from sim from graph from any future Tool. This isn't a stylistic choice — it's an architectural commitment that the layer policy enforces and that buys you the only thing that makes the platform shape-consistent over time: the freedom to add a tool without touching the kernel.

Bundled tools (fit/sim/graph/yagni) and installed or project-local tools load

through the same path (ADR-0027).

The only thing distinguishing them is their **source of installation, never

their lifecycle**.

What you'll understand after this:

- What the Tool contract looks like and why it has the shape it does.

- How a tool declares its commands as data (commandSpecs) that the host mounts.

- How tools get discovered and admitted (manifest + apiVersion), bundled and third-party alike.

- What you write to add another tool.


The contract

A Tool is a TypeScript object. The whole interface lives at packages/core/src/tools/types.ts; the load-bearing members are:

interface Tool {
  identity: { name: string; aliases?: readonly string[] };
  metadata: { id: string; version: string; description: string };
  commands: ReadonlyArray<{ name: string; description: string; aliases?: readonly string[] }>;
  commandSpecs?: ReadonlyArray<CommandSpec<unknown, ToolCliContext>>;
  extensionPoints?: {
    initialize?: () => Promise<void>;
    contributeScope?: () => ScopeContribution;          // per-run subscope (registries, etc.)
    collectReportData?: (scope: ToolScope) => Record<string, unknown>;
    config?: ToolConfigDeclaration;                      // a namespaced Zod schema block
    capabilityRegistrars?: Record<string, CapabilityRegistrar>;
    sessionReplay?: { tool: string; replaySession: (stored) => unknown };
  };
}

A Tool is anything that satisfies that shape. metadata, commands, and commandSpecs are the parts every tool fills in; the rest are opt-in seams the host wires only if present.

Why this exact shape

The contract has been deliberately kept narrow. Each core member exists for a specific reason:

The optional contribution slots under extensionPoints

(contributeScope, collectReportData, config, capabilityRegistrars,

sessionReplay) let a tool plug into the host's per-run scope, the cross-tool

HTML report, the composed config document, a capability domain it owns, and

sessions show replay — each only if the tool declares it. The sessions show

surface (and the new agent-catalog discovery command) now include agent

ergonomics such as --filter and --raw for focused historical inspection.

Lifecycle hook ordering

Hooks are self-initializing. The host guarantees only the call site that owns

the hook:

| Hook | Host call site | Ordering guarantee |

|---|---|---|

| initialize | pre-action for the invoked command | runs once before that command handler |

| contributeScope | run-scope construction | runs when the scope is built |

| capabilityRegistrars | capability-domain loading | runs when the owning command loads that domain |

| collectReportData | report composition | runs when a report is generated |

| sessionReplay | sessions show / --show | runs only for replay projection |

If a report, replay, or capability hook depends on setup, put an idempotent

ensureInitialized() in the tool package and call it from that hook. Do not rely

on a normal command having run first.

When a TypeScript tool contributes a typed subscope, keep the module

augmentation in a leaf file and import it for side effects from the tool entry:

// scope-augmentation.ts
import type { ScopeContribution } from '@opensip-cli/core';

export interface AuditScope {
  readonly cache: Map<string, unknown>;
}

declare module '@opensip-cli/core' {
  interface ScopeContribution {
    audit?: AuditScope;
  }
}
// index.ts
import './scope-augmentation.js';

export const tool = {
  // ...
  contributeScope: () => ({ audit: { cache: new Map() } }),
};

The side-effect import is intentional: it makes scope.audit visible to the

TypeScript compiler wherever the tool entry is loaded. First-party tools use the

same pattern in their tool.ts entry modules.

Tool contract versions (ADR-0046 / ADR-0074)

The core TOOL_CONTRACT_VERSION (exported from @opensip-cli/core) is a marker for the generic Tool / ToolExtensionPoints / ToolCliContext "bus" surface. It is bumped only on actual changes to that surface and takes the major.minor of the CLI release that ships the change (it deliberately lags ordinary CLI releases).

Each tool also exports its own independent contract version constant for its rich domain surface (FITNESS_CONTRACT_VERSION, GRAPH_CONTRACT_VERSION, SIMULATION_CONTRACT_VERSION, …). Runtime descriptors publish these through the open map:

extensionPoints: {
  contractVersions: {
    fitness: FITNESS_CONTRACT_VERSION,
  },
}

Keys are stable domain ids (fitness, graph, simulation, yagni, …). Core does not validate domain-specific version semantics. See ADR-0046 (core), ADR-0074 (open map + epoch ranges), and the JSDoc on each constant.

The ToolCliContext shape

The context object is the inversion-of-control seam. A tool needs to render results, but it doesn't depend on Ink. It needs to set the exit code, but it doesn't mutate process.exitCode. The host provides those operations through the context:

interface ToolCliContext {
  scope: ToolScope;                              // per-run resources (logger, registries, datastore, project)
  render: (result: unknown) => Promise<void>;    // render a CommandResult through the shared seam
  registerLiveView: (key: string, renderer: LiveViewRenderer) => void;
  renderLive: (key: string, args: unknown) => Promise<void>;
  maybeOpenReport: (opts: { openRequested: boolean; jsonOutput: boolean }) => Promise<void>;
  emitJson: (value: unknown) => void;            // the sanctioned --json stdout seam
  emitEnvelope: (envelope: SignalEnvelope) => void;
  emitError: (error: unknown) => void;
  reportFailure: (detail: ReportFailureDetail) => Promise<void>;
  writeArtifact: (path: string, bytes: string) => Promise<void>;
  writeSarif: (envelope: SignalEnvelope, path: string) => Promise<void>;
  deliverSignals: (envelope: SignalEnvelope, opts?: unknown) => Promise<unknown>;
  setExitCode: (code: number) => void;           // the only writer of the final exit code
  logger: Logger;
  toolState: ToolState;
  runSession: ToolRunSessions;
  hostPlanes?: { governance?: unknown; audit?: unknown; entitlements?: unknown };
}

This context carries no Commander program. A handler has no raw-Commander

handle to reach, so "one command surface" is structural, not merely guarded —

the host owns the program internally and mounts each commandSpec itself.

registerLiveView(key, renderer) / renderLive(key, args) are the stateful UI seam. A tool that wants a streaming spinner-to-results experience registers its own renderer under a key (lazily, from a setup hook on first live render) and invokes it by key. The live-view registry is owned by bundled in-process tools, not by external manifest-only command shells. External tool manifests cannot declare output: "live-view"; validation rejects that shape before runtime load because the host cannot execute an external renderer in-process. Adding a bundled live view is a tool-side change, not a contract change.


How tools get loaded

The flow lives in packages/cli/src/bootstrap/register-tools.ts and runs once, at process startup, before argv is parsed. Every tool — bundled or installed — travels the same admission path:

1. Construct a fresh ToolRegistry for this invocation:
   const toolRegistry = new ToolRegistry();

2. Bundled tools load by PACKAGE NAME (not a static import):
   The list is data-driven from `packages/cli/src/bootstrap/bundled-tools.manifest.json`
   (Workstream A). For each: loadToolManifest → admitTool → dynamic import → register.
   The host holds NO `import { fitnessTool }` — the `no-bootstrap-tool-import`
   fitness check fails the build if a static tool-runtime import creeps back.

   To add a new first-party (bundled) tool: add its npm package name (and id for
   scaffolding expectation) to the manifest JSON; the uniform admission path is
   used automatically. Update contributor docs + the architecture ratchet if
   needed.

3. Discovery (third-party): walk, in precedence order, the project's
   .runtime/plugins/tool/ → the project node_modules → the user-global
   ~/.opensip-cli/plugins/tool/ → the CLI's own install tree, for any
   package whose package.json declares opensipTools.kind === 'tool'. Each
   travels the identical loadToolManifest → admitTool → import → register path.

4. admitTool gates every candidate:
   - apiVersion check (compatibility.ts): a tool that declares no `apiVersion`
     is INCOMPATIBLE and not admitted; an epoch outside
     `MIN_SUPPORTED_PLUGIN_API_VERSION..PLUGIN_API_VERSION` is rejected with an
     upgrade hint (too old → upgrade the tool; too new → upgrade OpenSIP CLI).
   - assertManifestMatchesTool (manifest-assert.ts): the static manifest's
     `id` + command-name SET must equal the imported Tool's — a typed throw
     on drift, so a half-renamed command fails fast.

5. Mount: mountAllToolCommands walks the registry and mounts each tool's
   declared commandSpecs via mountCommandSpec. The host owns the Commander
   program; tools never see it.

6. Parse argv, then (lazy) initialize: when a subcommand is about to run, the
   CLI resolves the owning tool and calls its `extensionPoints.initialize()`
   once per process,
   after the run scope is entered. Uninvoked tools pay nothing.

This is the parity cutover's core: install-source independence is structural, not merely tested. A bundled tool is loaded by the same loadToolManifest → admitTool → dynamic import → register → mountCommandSpec sequence an installed or project-local tool is.

The discovery manifest

A third-party tool advertises itself with an opensipTools block in its package.json — read before its module is imported, so the host can admit it cheaply:

{
  "name": "@yourorg/audit-sec",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
  "opensipTools": {
    "kind": "tool",
    "id": "audit-sec",
    "apiVersion": 1,
    "commands": [
      { "name": "audit-sec", "description": "Run security audit" }
    ]
  }
}

The package's main entry must export a tool symbol that satisfies the Tool contract, whose metadata.id and command-name set match the manifest:

// dist/index.js
export const tool = {
  metadata: { id: 'audit-sec', version: '1.0.0', description: 'Security audit checks' },
  commands: [{ name: 'audit-sec', description: 'Run security audit' }],
  commandSpecs: [/* defineCommand({ name: 'audit-sec', … }) */],
};

Once installed, the CLI picks it up at next launch — no config edit, no code change in cli or core. A project-local pin shadows a user-global install of the same tool.


Why this isn't entry-points or hooks

A few alternatives were considered. Worth knowing why they're not what's here.

commands as data (commandSpecs) and the host builds the Commander tree,

applies the shared cross-tool flags, and owns parse → handler → render →

--json → exit. Letting every tool touch Commander would make "the same flag

means the same thing across tools" a convention rather than an invariant.

Centralizing the wiring makes it structural — see

ADR-0027 and

ADR-0021.


What you write to add another tool

The minimum viable tool, end-to-end:

// packages/audit-sec/src/index.ts
import { definePrimaryCommand, defineTool, type ToolCliContext } from '@opensip-cli/core';
import { runAudit } from './audit.js';

export const tool = defineTool({
  identity: { name: 'audit-sec' },
  metadata: {
    id: '0c9d1b75-1d6c-4d42-a2f7-76907c3f0181',
    version: '1.0.0',
    description: 'Lightweight security audit',
  },
  commandSpecs: [
    definePrimaryCommand<{ cwd: string }, ToolCliContext>({
      description: 'Run the audit',
      commonFlags: ['cwd', 'json'],   // shared flags arrive for free; never declare --json yourself
      scope: 'project',
      output: 'command-result',       // host renders the result + wraps --json as a CommandOutcome
      handler: async (opts, cli) => {
        const result = await runAudit(opts.cwd);
        cli.setExitCode(result.passed ? 0 : 1);
        return result;                // return your domain result — the host owns rendering / --json / exit
      },
    }),
  ],
});
// packages/audit-sec/package.json
{
  "name": "@yourorg/audit-sec",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
  "opensipTools": {
    "kind": "tool",
    "id": "audit-sec",
    "identity": { "name": "audit-sec" },
    "apiVersion": 1,
    "commands": [{ "name": "audit-sec", "description": "Run the audit" }]
  },
  "peerDependencies": {
    "opensip-cli": "^0.1.14",
    "@opensip-cli/core": "^0.1.14"
  }
}

For project-local authoring inside a repo, opensip tools create scaffolds

conformant sidecars (minimal-js or ts-local). The typed template uses

createTool() — a thin wrapper over defineTool() that accepts a

primaryCommand plus optional nested subcommands without synthesizing lifecycle

hooks (ADR-0076).

Recipe-capable tools share listing helpers in core (recipeDisplayInfo, neutral

selectionLabel on ListRecipesResult) but keep execution domain-owned — fitness

runs checks, graph selects rules only, simulation runs scenarios.

That's the whole tool. Add @yourorg/audit-sec to the project (or run opensip tools install @yourorg/audit-sec), and opensip audit-sec works. For discoverability verbs (list, recipes, export) use defineNestedCommand; defineTool mounts them under identity.name — see Command surface taxonomy. For the full walkthrough — installation modes, per-command options, kernel-registry reuse — see Full Tool plugins.

What you don't need:

If your tool also wants to ship checks (the way @opensip-cli/checks-typescript does for fit), that's a separate, lighter contract — a check pack declaring the fit-pack marker plus target-domain epoch. See 50-extend/01-plugin-authoring.md.


What this buys you

Three things, in order of importance:


What's next