Full Tool plugins

A Tool plugin contributes its own subcommand. Use this when you want something fundamentally different from fit, sim, graph, or yagni — an audit-sec, a bench, a custom report. Anything that has its own argv shape, its own logic, and its own result type.

This is the heaviest extension shape. Most teams never need it. If you just want to ship rules, Publishable packs is the right path.

Project-local authoring paths

| Path | Command | When to use |

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

| minimal-js | opensip tools create <id> | Zero-dependency smoke tests inside a repo |

| ts-local | opensip tools create <id> --template ts-local | Typed authoring with defineTool() before packaging |

| Publishable npm | opensip tools install <spec> | Distribution to other repos (deferred scaffold — see ADR-0076) |

defineTool() is the canonical typed local tool contract. createTool() remains

a compatibility wrapper for older authored tools, but new scaffolds use

defineTool() plus command-spec drafts directly. Neither path synthesizes

lifecycle extensionPoints — absence is the safe default

(ADR-0076).

Once a Tool exists as a package, the customer-facing management surface is the tools command group: tools list, tools validate, tools install, tools uninstall, and tools data-purge.

Layout

@my-co/audit-sec/
├── package.json
├── src/
│   ├── index.ts                # exports: tool
│   ├── audit.ts                # the actual logic
│   └── …
├── dist/
└── README.md

package.json

{
  "name": "@my-co/audit-sec",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
	  "opensipTools": {
	    "kind": "tool",
	    "id": "audit-sec",
	    "identity": { "name": "audit-sec" },
	    "apiVersion": 1,
	    "requires": [
	      { "resource": "filesystem", "access": "read", "scope": "project" }
	    ],
	    "commands": [
	      { "name": "audit-sec", "description": "Run the security audit" },
	      { "name": "list", "parent": "audit-sec", "description": "List audit rules" },
	      { "name": "recipes", "parent": "audit-sec", "description": "List audit recipes" },
	      { "name": "export", "parent": "audit-sec", "description": "Export audit artifacts (--format sarif)" }
	    ]
	  },
  "peerDependencies": {
    "@opensip-cli/contracts": "^0.8.5",
    "@opensip-cli/core": "^0.8.5"
  }
}

The opensipTools block is your tool's static manifest — read before your module is imported, so the host knows what it's admitting:

when MIN_SUPPORTED_PLUGIN_API_VERSION <= apiVersion <= PLUGIN_API_VERSION

(currently 1..1). A tool that declares no apiVersion is not admitted (it

fail-closes when run explicitly, or is skipped with a diagnostic when discovered).

normalizes and hashes them for manifest provenance/trust UX, but they are not

a sandbox. An admitted external tool still runs with the current user's OS

privileges.

Peer-dep on @opensip-cli/contracts and @opensip-cli/core at ^0.1.0; the

consumer brings their own version. (While opensip-cli is pre-1.0, a ^0.x

caret locks to the minor — ^0.1.0 is >=0.1.0 <0.2.0 — so bump your peer

range when you adopt a new 0.y line.)

src/index.ts

A tool declares its commands as typed CommandSpecs and the host mounts them —

owning the common flags (--cwd, --json, …), parsing, help, completion, output

dispatch, and exit policy. You write a handler and a declaration; everything else

arrives for free, identically to a bundled tool. You never touch Commander, never

add --json yourself, and never write to stdout — the host renders your result and

wraps --json in a CommandOutcome.

| CommandSpec.output | Use for | External Tool support |

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

| command-result | Normal commands that return a renderable result | Supported |

| raw-stream | File export or worker/transport commands that own their stream | Supported, with rawStreamReason |

| live-view | Bundled in-process tools that register a renderer | Not supported for external manifests; validation fails fast |

import {
  defineNestedCommand,
  definePrimaryCommand,
  defineTool,
  type ToolCliContext,
} from '@opensip-cli/core';
import { listAuditRecipes, listAuditRules, 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',
  },
  // defineTool derives metadata.name, the primary command name, aliases,
  // nested parents, and commands[] from identity + these specs.
  commandSpecs: [
    definePrimaryCommand<{ cwd: string }, ToolCliContext>({
      description: 'Run the security audit',
      commonFlags: ['cwd', 'json'],
      scope: 'project',
      output: 'command-result',
      handler: async (opts, cli) => {
        const result = await runAudit(opts.cwd);
        cli.setExitCode(result.passed ? 0 : 1);
        return result;
      },
    }),
    defineNestedCommand<{ cwd: string }, ToolCliContext>({
      name: 'list',
      description: 'List audit rules',
      commonFlags: ['cwd', 'json'],
      scope: 'project',
      output: 'command-result',
      handler: async (opts) => listAuditRules(opts.cwd),
    }),
    defineNestedCommand<{ cwd: string }, ToolCliContext>({
      name: 'recipes',
      description: 'List audit recipes',
      commonFlags: ['cwd', 'json'],
      scope: 'project',
      output: 'command-result',
      handler: async (opts) => listAuditRecipes(opts.cwd),
    }),
    defineNestedCommand<{ cwd: string; out: string }, ToolCliContext>({
      name: 'export',
      description: 'Export audit artifacts',
      commonFlags: ['cwd', 'json'],
      options: [
        {
          flag: '--format',
          value: '<fmt>',
          required: true,
          choices: ['sarif'],
          description: 'Export artifact: sarif',
        },
        { flag: '--out', value: '<path>', required: true, description: 'Output file path' },
      ],
      scope: 'project',
      output: 'raw-stream',
      rawStreamReason: 'file-export',
      handler: async (opts, cli) => {
        // file-writing export — same pattern as `fit export` / `graph export`
        await cli.writeSarif(/* … */, opts.out);
      },
    }),
  ],
});

Output modes

A command declares one output mode on its CommandSpec; that mode determines

the single path your handler uses to produce output. You never write to stdout or

add --json yourself — the host renders your result and wraps --json in a

CommandOutcome.

| output mode | What your handler does | Host behavior |

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

| command-result (default) | return a CommandResult (e.g. { type: 'text-lines', … }) | Renders for humans; wraps the result under CommandOutcome for --json |

| signal-envelope | return a SignalEnvelope (or cli.emitEnvelope(env)) | Wraps the envelope under CommandOutcome.envelope; routes baseline/SARIF/cloud seams |

| raw-stream | cli.emitRaw(...) (requires a rawStreamReason) | Writes your bytes verbatim — for human status lines / file-export confirmations, not machine JSON |

| live-view | cli.renderLive(key, args) | Renders an Ink/TTY live view. Bundled/in-process tools only — external manifest tools may not declare it (see External tool trust boundary) |

Error path (any mode). To fail a command, either throw a catalog-backed

ToolError via createToolError (the host maps definition axes to an exit code)

or call cli.reportFailure({ error, … }) — the host normalizes once

(normalizeFailure), derives the message/exit code, logs, renders the customer

surface, and sets the exit code. Do not process.exit, pre-stringify failures, or

format error JSON yourself. Prefer a package-owned

extensionPoints.errorCatalog (contract ≥ 1.1.0) so codes stay stable across

hosts. See Command failures vs findings and the

error and resiliency model.

Logging and operational telemetry

During a normal command run, cli.logger resolves to the per-run scope logger

and writes structured JSONL under <project>/opensip-cli/.runtime/logs/ when the

host configures a log directory. Prefer stamping a stable module on every entry:

import { createToolLogger } from '@opensip-cli/core';

const log = createToolLogger('audit-sec:cli');

handler: async (opts, cli) => {
  log.info({ evt: 'audit-sec.run.start', cwd: opts.cwd });
  // …
};

Or call cli.logger directly with both evt and module fields. Event names follow

the three-segment convention documented in Coding standards.

--debug may mirror log lines to stderr; that channel is for operators, not

customer-facing command output.

Command failures vs findings

| Situation | Seam |

|---|---|

| Scan/analysis results (signals, score, verdict) | Build a SignalEnvelope and return it (or call cli.deliverSignals after render) |

| Command cannot run (missing file, bad config, not found) | await cli.reportFailure({ … }) |

| Uncaught ToolError in a handler | Host catches and calls reportFailure for you |

| Durable artifact export | await cli.writeArtifact(path, bytes) (or a narrower host seam such as cli.writeSarif) |

reportFailure fans out to structured log, human Ink / --json error CommandOutcome,

exit code, and diagnostics — the host owns routing. Example with an optional

immutable error catalog:

import { createToolError, defineErrorCatalog, defineTool } from '@opensip-cli/core';

const errorCatalog = defineErrorCatalog(
  { id: '11111111-1111-4111-8111-111111111111', displayName: 'audit-sec', packageName: '@my-co/audit-sec' },
  {
    'AUDIT.TARGET.MISSING': {
      code: 'AUDIT.TARGET.MISSING',
      source: 'application',
      defaultResponsibility: 'user',
      kind: 'not-found',
      retry: 'never',
      severity: 'error',
      exposure: 'public',
      exitClass: 'not-found',
      operatorAction: 'Pass an existing path under the project root.',
      stability: 'public',
      lifecycle: 'active',
    },
  },
);

export const tool = defineTool({
  // … identity, commandSpecs …
  extensionPoints: {
    // Attach the immutable catalog directly (matches ToolExtensionPoints.errorCatalog).
    errorCatalog,
  },
  // handlers throw createToolError(errorCatalog.require('AUDIT.TARGET.MISSING'), '…')
});

handler: async (opts, cli) => {
  try {
    cli.logger.info({ evt: 'audit-sec.run.start', module: 'audit-sec:cli' });
    return await runAudit(opts.cwd);
  } catch (error) {
    // Pass the thrown value through — do not String(error) first.
    await cli.reportFailure({ error, jsonRequested: opts.json === true });
    return;
  }
};

Malformed or colliding catalogs are rejected at load with a registered safe

failure (never a silent half-mount). Older hosts may ignore the optional

contribution; new hosts validate schema version and owner identity. See

ADR-0077,

ADR-0181.

defineTool derives commands[] from commandSpecs (including parent for

nested children). The manifest lists every command by short namelist,

recipes, export — not as nested paths; external-host mounting uses the

serializable parent field in the manifest command shell.

See Command surface taxonomy for the full Tier-1/2/3

grammar.

The manifest and derived commands[] must agree on the command-name set. The

host asserts this at load, so a half-renamed command fails fast with a clear

error instead of a silent half-mounted surface.

That's the whole tool. Install it either way and opensip audit-sec works on the next invocation:

Installed npm tools found ambiently in node_modules are deny-by-default. The

managed path is opensip tools install: it validates the package, installs the

validated bytes, and records trust for the selected scope. The tools install

result includes nextSteps with the first command to try:

opensip audit-sec

OPENSIP_CLI_ALLOW_INSTALLED_TOOLS still accepts exact ids for manual

experiments or incident response. The * wildcard is accepted but warns because

it admits every discovered installed Tool.

Authored Tool sidecars (tracked, no npm install)

The routes above all package your tool as npm (provenance installed). A

second mechanism lets you author a Tool as tracked source — the

whole-subcommand analogue of the opensip-cli/fit/checks/ and

opensip-cli/sim/scenarios/ convention — with no npm install and no

package.json marker. You declare identity via an opensip-tool.manifest.json

sidecar next to the tool's built entry, in one of two locations with

different trust postures:

committed alongside opensip-cli/fit/ and opensip-cli/sim/. It is

deny-by-default: it rides in with a git clone before you've read it, so

loading it would run untrusted code. It is admitted only when its id

appears in committed project config under tools.trusted or in the

OPENSIP_CLI_ALLOW_PROJECT_TOOLS override; otherwise the CLI **fail-closes

(exit 5) before importing it**. Provenance is project-local.

you placed it in your own home dir (the npm i -g analogue for authored code),

so it loads without an allowlist. Provenance is user-global.

The sidecar is the manifest block (there is no package.json alongside it),

carrying the same identity fields inline — kind, id, identity, name,

version, apiVersion, commands — plus the path to the tool's own resolved

main entry:

// <project>/opensip-cli/tools/audit-sec/opensip-tool.manifest.json
{
	  "kind": "tool",
	  "id": "audit-sec",
	  "identity": { "name": "audit-sec" },
	  "name": "Security audit",
  "version": "1.0.0",
  "apiVersion": 1,
  "main": "dist/index.js",
	  "commands": [
	    { "name": "audit-sec", "description": "Run the security audit" },
	    { "name": "list", "parent": "audit-sec", "description": "List audit rules" },
	    { "name": "recipes", "parent": "audit-sec", "description": "List audit recipes" },
	    { "name": "export", "parent": "audit-sec", "description": "Export audit artifacts (--format sarif)" }
	  ]
}

The runtime contract is unchanged — the directory's resolved main must export

tool: Tool, and the host runs the same assertManifestMatchesTool drift guard.

Authored discovery, admission, dynamic import, and registration travel the exact

same path bundled and installed tools do (ADR-0030).

Sidecar vs tools install --project. tools install --project *installs an

npm package* into the gitignored .runtime/plugins/tool/ and keeps provenance

installed. An authored sidecar is tracked source with provenance

project-local (project) or user-global (home). They are different

mechanisms; the provenance label in opensip tools list tells them apart.

What you don't need

The Tool contract is the seam. The CLI builds a per-invocation ToolRegistry,

discovers your package via the opensipTools.kind: 'tool' marker, admits it

(manifest + apiVersion check), dynamically imports it, and mounts your

declared commandSpecs via mountCommandSpec. commandSpecs is the one

command surface. For the architecture behind this decoupling, see

the tool-plugin model.

Contract versions (core TOOL_CONTRACT_VERSION + per-tool versions such as

FITNESS_CONTRACT_VERSION) are documented in the same model page and the

governing ADRs (0046/0047). Declare the relevant ones on your Tool object

(under extensionPoints is the preferred path) so hosts and agent-catalog

can see the exact surface you were written against.

Recipe listing (shared display, tool-owned execution)

Fitness, graph, and simulation expose recipes list commands that return the

shared ListRecipesResult shape. Core provides display-only helpers

(recipeDisplayInfo, allUnitsLabel, …) for name/description/tags and a neutral

selectionLabel. Each tool still owns selector semantics and execution:

There is no generic recipe execution framework in core.

Tools that use the kernel registries

A Tool that wants to reuse the fitness check registry (e.g. an audit-fit that runs a custom recipe) imports @opensip-cli/fitness and reuses executeFit, defineRecipe, etc. The fitness package re-exports these so a Tool author doesn't have to assemble a runner from scratch.

A Tool that's structurally different (a benchmark runner, a custom report generator) doesn't need to import @opensip-cli/fitness at all — it can be entirely self-contained, with its own logic. For terminal output, return an existing renderable CommandResult shape such as text-lines; new host-specific result variants require a CLI/contracts change because the render mapping is intentionally closed and exhaustive.

Participating in init scaffolding

opensip init is registry-driven: it scaffolds one directory tree per registered tool, and your tool owns its example bytes. To opt in, declare three optional Tool members — the host owns the directory layout, the document header, and targets:; you own everything inside your domain:

No packages/cli change is needed to add a tool to init — the scaffolded set is exactly the registered set.

Per-command options: one interface per command

Each built-in command has its own options interface in @opensip-cli/contracts, and that interface is the single source of truth for the command's flags — the executor takes it directly (executeFit(args: FitOptions, …), executeSim(args: ToolOptions), executeInit(args: InitOptions & {…})).

| Command | Options interface |

|---|---|

| fit | FitOptions |

| sim | ToolOptions |

| init | InitOptions |

| your tool | a new interface in your tool package, named after the command |

New flags are additive on the relevant interface. There is no shared cross-command union — each command's shape stands on its own.

For your own Tool plugin you don't reuse any of these: you declare each

command's options as OptionSpecs on its commandSpec, and the host wires

Commander and passes the parsed options to your handler as the first argument.

You never touch Commander or take a commander dependency — the host owns the

program.

External tool trust boundary (ADR-0054, ADR-0061)

Bundled first-party tools (fitness, graph, simulation) execute in the CLI host

process and are fail-closed on admission or mount failure (exit 5).

External-provenance tools (installed npm, project-local, user-global) use a

different posture — fault isolation, not capability isolation:

manifest via synthesizeExternalTool and does not import the untrusted

runtime module in the host process.

__tool-command-worker child that loads and runs the real runtime in a

fault-isolation boundary; results replay through the same ToolCliContext seams.

worker, not the host.

host-side runtime imports outside the admission/dispatch modules.

An admitted external tool runs at full user privilege: it can read the filesystem (including ~/.ssh and .env), and make arbitrary network calls. It is fault-isolated (a crash/hang/OOM does not take down the host), not capability-isolated.

The worker does deny ambient OpenSIP datastore access. Both the exact internal

worker command and the host-owned marker must select host-rpc-only mode;

either alone fails before the handler runs. Privileged OpenSIP effects are the

enumerated host RPC methods, not a general datastore or arbitrary host call.

The child environment is an explicit allowlist plus manifest-declared env

resources; additional names require OPENSIP_CLI_TOOL_ENV_PASSTHROUGH. These

controls do not reduce the current user's ordinary Node filesystem/network

authority and must not be described as an OS sandbox

(ADR-0145).

Capability packs (custom checks, scenario packs, graph adapters loaded via

plugins.<domain>) follow a split posture

(ADR-0171):

bundled first-party packs load in the host process; admitted external packs run

through the owning tool's capability worker bridge. That worker's resource

guard is sharpened-advisory defense-in-depth, not containment: it exists to

catch accidental undeclared filesystem/network/subprocess use by

operator-admitted code (and its diagnostic says so). **Admission is the

enforced boundary** — an admitted pack runs with the host user's real

filesystem and network authority. True containment is deferred to the

process-level plugin-isolation roadmap. The external-tool worker fork above

does not cover capability packs.

For the full extension trust-tier matrix, see

ADR-0061.

What is enforced at admission:

(admit-tool-package.ts).

output: "raw-stream"; output: "live-view" is rejected by admission and by

tools validate. A live-view renderer is executable UI code and cannot be

mounted from a manifest-only external tool shell.

authored tools are admitted by tools.trusted (or the

OPENSIP_CLI_ALLOW_PROJECT_TOOLS override). Installed tools are admitted by a

managed tools install trust record (or the OPENSIP_CLI_ALLOW_INSTALLED_TOOLS

override). Trust entries are exact ids. A * entry is ignored and emits one

bounded cli.trust.tool_wildcard_ignored warning; it never admits a Tool.

Managed install records and committed exact project trust remain the normal

paths (ADR-0145).

(ADR-0171).

Bundled first-party packs are trusted. A plugins.checkPackages,

plugins.scenarioPackages, or plugins.graphAdapters entry selects a

pack for discovery but never trusts it — the analyzed repo's own config is

exactly the surface an analysis tool must not take trust decisions from.

External packs are admitted only by the user-level global-config trust list,

written by the operator ceremony opensip policy trust <pack>: each grant

binds the exact package id to the pack's provenance (its opensipTools

manifest hash) resolved at grant time, so a repo shadowing a trusted name

with different code is denied — re-run policy trust after the pack

legitimately changes. The former OPENSIP_CLI_ALLOW_CAPABILITY_PACKS env

override is removed, and * is never honored for capability packs.

continues; bundled mount failures abort startup (exit 5).

(staticOnly on the admission pipeline).

What is not landed yet: consumption-side npm package verification at

install/load (publish-side npm publish --provenance already ships —

.github/workflows/release.yml:236,248). Public third-party ecosystem launch is

blocked until consumption-side verification + a capability/permission model ship

(ADR-0061). Until then, pin versions, review source, and use opensip tools validate

before enabling a new tool in CI.

Tips that come up

Where to go next