From 4f27304bbc154d5a2b0d0cbe137a3ed5e76b8f2d Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sat, 27 Jun 2026 19:07:48 -0700 Subject: [PATCH 1/2] Add oclif runner and core-plugin host Export run()/execute()/loadConfig()/listCorePlugins() so a consuming CLI runs BaseCommand subclasses and discovers commands contributed by packages declared as oclif core plugins (package.json oclif.plugins intersected with dependencies). Wire BaseCommand.prerun into the oclif init lifecycle so capability enforcement actually runs. Move @oclif/core to runtime dependencies. (FR-015) Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 5 +- src/commands/base-command.ts | 5 + src/index.ts | 13 +++ src/runtime/runner.ts | 115 ++++++++++++++++++++ tests/runner.test.ts | 198 +++++++++++++++++++++++++++++++++++ 5 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 src/runtime/runner.ts create mode 100644 tests/runner.test.ts diff --git a/package.json b/package.json index 24841e9..99ba551 100644 --- a/package.json +++ b/package.json @@ -42,16 +42,13 @@ "@agent-ix/ts-plugin-kit": ">=0.1.3", "@clack/prompts": ">=1.5.1", "@napi-rs/keyring": ">=1.3.0", + "@oclif/core": ">=4.11.4", "age-encryption": ">=0.3.0", "react": ">=19.2.7", "yaml": ">=2.9.0", "zod": ">=4.4.3" }, - "peerDependencies": { - "@oclif/core": ">=4.11.4" - }, "devDependencies": { - "@oclif/core": ">=4.11.4", "@types/node": ">=25.9.2", "@types/react": ">=19.2.17", "@typescript-eslint/eslint-plugin": ">=8.60.1", diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 0ed56fa..e037e2f 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -61,6 +61,11 @@ export abstract class BaseCommand extends Command { projectConfigRoot: noProject ? undefined : join(process.cwd(), ".ix"), projectConfigEnabled: noProject !== true, }); + // Enforce declared capability requirements as part of the oclif lifecycle + // so the runner (FR-015) short-circuits commands whose required + // capabilities are unavailable before `run()` executes. `prerun()` is a + // no-op for commands that declare no capabilities. + await this.prerun(); } public async prerun(): Promise { diff --git a/src/index.ts b/src/index.ts index 2b5cf8f..0a1fb51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,19 @@ export { export { BaseCommand } from "./commands/base-command.js"; export type { CommandCapabilities } from "./runtime/capability-spec.js"; +// ── oclif runner + core-plugin host (FR-015) ─────────────────────────── +// Lets a consuming CLI (quoin) run BaseCommand subclasses and commands +// contributed by packages declared as oclif core plugins via a single +// `import { run } from "@agent-ix/ix-cli-core"; run()` entry point. +export { + run, + execute, + loadConfig, + listCorePlugins, + type RunnerLoadOptions, + type CorePluginInfo, +} from "./runtime/runner.js"; + // ── Marketplace adapter over @agent-ix/ts-plugin-kit (FR-019) ────────── // Thin wiring: ix-cli-core adapts the external marketplace library (cache // layout + oclif command-plugin bridge); it does NOT implement an installer. diff --git a/src/runtime/runner.ts b/src/runtime/runner.ts new file mode 100644 index 0000000..77383c3 --- /dev/null +++ b/src/runtime/runner.ts @@ -0,0 +1,115 @@ +import { + Config, + execute as oclifExecute, + run as oclifRun, + type Interfaces, +} from "@oclif/core"; + +/** + * oclif runner + core-plugin host for IX CLIs (FR-015). + * + * A consuming binary (e.g. quoin) ships a thin `bin` script that simply + * delegates to this runner: + * + * ```js + * #!/usr/bin/env node + * import { run } from "@agent-ix/ix-cli-core"; + * await run(undefined, import.meta.url); + * ``` + * + * The runner is a wafer-thin wrapper over `@oclif/core`. Command discovery + * (the consumer's own `oclif.commands` dir) and **core-plugin** discovery + * (packages listed in the consumer's `package.json` `oclif.plugins` array + * that are also declared as `dependencies`) are performed by `@oclif/core`'s + * own `Config` loader. ix-cli-core never imports `@oclif/plugin-plugins`: + * runtime, user-installed plugins are out of scope — only **bundled** core + * plugins shipped as dependencies of the host CLI are loaded. + * + * `BaseCommand` subclasses contributed by either the host or a core plugin + * run unchanged: their base flags (`--config-root`, `--no-project-config`) + * and capability hooks are wired through `init()`/`prerun()` exactly as they + * are when run directly. + */ + +/** + * Options accepted by {@link run}: a pre-loaded {@link Config}, a directory / + * file-URL string (e.g. `import.meta.url`), an oclif `Options` object, or + * `undefined` to fall back to the caller's module location. + */ +export type RunnerLoadOptions = Interfaces.LoadOptions; + +/** + * Run an IX CLI from `argv`. + * + * Loads the consuming CLI's oclif {@link Config} from `options` (resolving its + * `commands` dir and core `plugins`), then dispatches the requested command. + * Returns the command's result; throws on error (it does **not** call + * `process.exit`, so it is safe to use in tests). Use {@link execute} for a + * top-level bin that should handle errors and set the process exit code. + * + * @param argv argument vector (defaults to `process.argv.slice(2)`) + * @param options config source — defaults to oclif's own resolution + */ +export async function run( + argv?: string[], + options?: RunnerLoadOptions, +): Promise { + return oclifRun(argv ?? process.argv.slice(2), options); +} + +/** + * Load-and-run entry point for a top-level bin script. + * + * Thin pass-through to `@oclif/core`'s `execute`, which loads the config from + * `dir` (typically `import.meta.url`), runs the command, flushes output, and + * handles errors / process exit codes. Prefer {@link run} in tests. + */ +export async function execute(options: { + args?: string[]; + development?: boolean; + dir?: string; + loadOptions?: RunnerLoadOptions; +}): Promise { + return oclifExecute(options); +} + +/** + * Load the consuming CLI's oclif {@link Config} without running a command. + * + * Exposes the host's resolved plugin/command graph so a CLI (or a test) can + * introspect what was discovered — including core plugins — before dispatch. + * The returned config can be passed straight back into {@link run} as + * `options` to avoid re-resolving. + */ +export async function loadConfig(options?: RunnerLoadOptions): Promise { + return Config.load(options); +} + +/** A core plugin discovered and loaded by the host. */ +export interface CorePluginInfo { + /** Package name of the plugin. */ + name: string; + /** Absolute path to the plugin package root. */ + root: string; + /** oclif plugin type — `core` for bundled host plugins. */ + type: string; + /** Command ids contributed by this plugin. */ + commandIDs: string[]; +} + +/** + * List the **core plugins** loaded into a {@link Config} (excludes the root/host + * plugin itself). These are the packages from the host's `oclif.plugins` that + * `@oclif/core` resolved from its dependencies. Useful for `doctor`/diagnostic + * output and for asserting plugin-host wiring in tests. + */ +export function listCorePlugins(config: Config): CorePluginInfo[] { + return [...config.plugins.values()] + .filter((p) => !p.isRoot && p.type === "core") + .map((p) => ({ + name: p.name, + root: p.root, + type: p.type, + commandIDs: [...p.commandIDs], + })); +} diff --git a/tests/runner.test.ts b/tests/runner.test.ts new file mode 100644 index 0000000..88f4ddc --- /dev/null +++ b/tests/runner.test.ts @@ -0,0 +1,198 @@ +import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { listCorePlugins, loadConfig, run } from "../src/index.js"; + +/** + * FR-015 / TC-015 — oclif runner + core-plugin host. + * + * Builds a throwaway "consumer CLI" on disk (a host package with its own + * `oclif.commands` dir plus a bundled core plugin declared in `oclif.plugins`) + * and drives it through the exported {@link run} runner. The fixture command + * modules are loaded natively by `@oclif/core`, so they import `BaseCommand` + * from the built package via a symlink into the fixture's `node_modules` — + * exactly how a real consumer (quoin) imports it. + */ + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const distEntry = join(repoRoot, "dist", "index.js"); + +let tmp: string; + +const consumerPkg = { + name: "@ixcc-fixture/consumer", + version: "0.0.0", + type: "module", + bin: { ixfix: "./bin/run.js" }, + oclif: { + bin: "ixfix", + commands: "./commands", + // Declare the bundled package as an oclif *core plugin*. oclif matches + // this against `dependencies` and loads it from node_modules. + plugins: ["@ixcc-fixture/hello-plugin"], + }, + dependencies: { "@ixcc-fixture/hello-plugin": "*" }, +}; + +const pluginPkg = { + name: "@ixcc-fixture/hello-plugin", + version: "0.0.0", + type: "module", + oclif: { commands: "./commands" }, +}; + +// Host command: a BaseCommand subclass. Writes its parsed base flags to a file +// so the test can assert end-to-end dispatch AND base-flag plumbing. +const greetCmd = ` +import { BaseCommand } from "@agent-ix/ix-cli-core"; +import { Flags } from "@oclif/core"; +import { writeFileSync } from "node:fs"; + +export default class Greet extends BaseCommand { + static description = "greet (host BaseCommand subclass)"; + static flags = { out: Flags.string({ required: true }) }; + async run() { + const { flags } = await this.parse(Greet); + writeFileSync( + flags.out, + "greet|config-root=" + + (flags["config-root"] ?? "") + + "|no-project=" + + flags["no-project-config"], + ); + this.log("greet ran"); + } +} +`; + +// Core-plugin command: also a BaseCommand subclass, contributed by the plugin. +const helloCmd = ` +import { BaseCommand } from "@agent-ix/ix-cli-core"; +import { Flags } from "@oclif/core"; +import { writeFileSync } from "node:fs"; + +export default class Hello extends BaseCommand { + static description = "hello (contributed by a core plugin)"; + static flags = { out: Flags.string({ required: true }) }; + async run() { + const { flags } = await this.parse(Hello); + writeFileSync(flags.out, "hello-plugin ran"); + } +} +`; + +// Host command declaring an unsatisfiable required capability. Used to prove +// the capability hook (prerun) fires through the runner's lifecycle. +const guardedCmd = ` +import { BaseCommand } from "@agent-ix/ix-cli-core"; + +export default class Guarded extends BaseCommand { + static description = "requires the github capability"; + static capabilities = { required: ["github"] }; + async run() { + this.log("should never run"); + } +} +`; + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, JSON.stringify(value, null, 2)); +} + +beforeAll(() => { + // The fixture command modules import the *built* package, mirroring a real + // consumer. Ensure dist exists (CI builds before test; build on demand for a + // clean local checkout). + if (!existsSync(distEntry)) { + execSync("pnpm run build", { cwd: repoRoot, stdio: "inherit" }); + } + + tmp = mkdtempSync(join(tmpdir(), "ixcc-runner-")); + + // ── host package ────────────────────────────────────────────────────── + writeJson(join(tmp, "package.json"), consumerPkg); + mkdirSync(join(tmp, "commands"), { recursive: true }); + writeFileSync(join(tmp, "commands", "greet.js"), greetCmd); + writeFileSync(join(tmp, "commands", "guarded.js"), guardedCmd); + + // ── node_modules: symlink the package under test + @oclif/core ───────── + mkdirSync(join(tmp, "node_modules", "@agent-ix"), { recursive: true }); + mkdirSync(join(tmp, "node_modules", "@oclif"), { recursive: true }); + mkdirSync(join(tmp, "node_modules", "@ixcc-fixture"), { recursive: true }); + symlinkSync( + repoRoot, + join(tmp, "node_modules", "@agent-ix", "ix-cli-core"), + "dir", + ); + symlinkSync( + join(repoRoot, "node_modules", "@oclif", "core"), + join(tmp, "node_modules", "@oclif", "core"), + "dir", + ); + + // ── core plugin (bundled dependency) ─────────────────────────────────── + const pluginRoot = join(tmp, "node_modules", "@ixcc-fixture", "hello-plugin"); + mkdirSync(join(pluginRoot, "commands"), { recursive: true }); + writeJson(join(pluginRoot, "package.json"), pluginPkg); + writeFileSync(join(pluginRoot, "commands", "hello.js"), helloCmd); +}); + +afterAll(() => { + if (tmp) rmSync(tmp, { recursive: true, force: true }); +}); + +describe("oclif runner + core-plugin host (FR-015 / TC-015)", () => { + it("discovers the host commands AND the core-plugin's commands", async () => { + const config = await loadConfig({ root: tmp }); + + const ids = config.commandIDs; + expect(ids).toContain("greet"); // host command + expect(ids).toContain("hello"); // contributed by the core plugin + + const core = listCorePlugins(config); + const plugin = core.find((p) => p.name === "@ixcc-fixture/hello-plugin"); + expect(plugin).toBeDefined(); + expect(plugin?.type).toBe("core"); + expect(plugin?.commandIDs).toContain("hello"); + }); + + it("runs a host BaseCommand subclass end-to-end, with base flags parsed", async () => { + const config = await loadConfig({ root: tmp }); + const out = join(tmp, "greet.out"); + + await run(["greet", "--out", out, "--config-root", "/custom/root"], config); + + expect(readFileSync(out, "utf8")).toBe( + "greet|config-root=/custom/root|no-project=false", + ); + }); + + it("runs a command contributed by the core plugin via the runner", async () => { + const config = await loadConfig({ root: tmp }); + const out = join(tmp, "hello.out"); + + await run(["hello", "--out", out], config); + + expect(readFileSync(out, "utf8")).toBe("hello-plugin ran"); + }); + + it("short-circuits a command whose required capability is unavailable", async () => { + const config = await loadConfig({ root: tmp }); + // The capability hook (prerun) runs in BaseCommand.init via the runner; + // with no provider registered, `github` is unavailable and the command + // must error before its run() body executes. + await expect(run(["guarded"], config)).rejects.toThrow(); + }); +}); From b46f0799fbb1c09b089c1aa1e12010e9886577d7 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Thu, 30 Jul 2026 13:53:36 -0700 Subject: [PATCH 2/2] Specify the oclif runner as FR-025 and close its coverage gaps Review of the runner branch found the new API had no owning requirement: runner.ts, index.ts and base-command.ts each cited FR-015, which in this repo is "Service Discovery Client". (The pre-existing FR-015..FR-018 citation on the auth engine is correct and untouched.) Adds FR-025 "oclif Runner and Core-Plugin Host", following how this repo specs its API surfaces (FR-001 ConfigService, FR-005 SecretsService), tracing to StR-003 and naming FR-010 downstream as the composition it enables. Corrects the three citations to FR-025. FR-025 was already spoken for, informally and wrongly: index.ts and plugins/schema.ts pointed the ixSchema convention at FR-025, and a plugin-schema test was named for it, though that convention is FR-014. Repointed all three so one ID does not mean two things. Two ACs had no test: - listCorePlugins excluding the root host plugin -- the host is itself a plugin in oclif's graph, so including it would misreport the host as its own dependency. - run() rejecting rather than calling process.exit, which is what makes it safe to drive from a test. Adds spec/tests.md rows for all eight ACs (six quoted test names verified to resolve; AC-8 is a static check, confirmed: no @oclif/plugin-plugins dependency), the runner.test.ts entry in the test-file map, and FR-025 to the StR-003 rollup. Indexes FR-024, which was missing. 245 passed, tsc/lint clean, quire validate exit 0. --- pnpm-lock.yaml | 6 +- spec/functional/FR-025-oclif-runner-host.md | 70 +++++++++++++++++++++ spec/functional/index.md | 2 + spec/tests.md | 21 +++++-- src/commands/base-command.ts | 2 +- src/index.ts | 4 +- src/plugins/schema.ts | 2 +- src/runtime/runner.ts | 2 +- tests/plugin-schema.test.ts | 2 +- tests/runner.test.ts | 21 ++++++- 10 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 spec/functional/FR-025-oclif-runner-host.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9097561..7b1236a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@napi-rs/keyring': specifier: '>=1.3.0' version: 1.3.0 + '@oclif/core': + specifier: '>=4.11.4' + version: 4.11.4 age-encryption: specifier: '>=0.3.0' version: 0.3.0 @@ -33,9 +36,6 @@ importers: specifier: '>=4.4.3' version: 4.4.3 devDependencies: - '@oclif/core': - specifier: '>=4.11.4' - version: 4.11.4 '@types/node': specifier: '>=25.9.2' version: 25.9.2 diff --git a/spec/functional/FR-025-oclif-runner-host.md b/spec/functional/FR-025-oclif-runner-host.md new file mode 100644 index 0000000..6972978 --- /dev/null +++ b/spec/functional/FR-025-oclif-runner-host.md @@ -0,0 +1,70 @@ +--- +id: FR-025 +title: "oclif Runner and Core-Plugin Host" +type: FR +relationships: + - target: "ix://agent-ix/ix-cli-core/spec/stakeholder/StR-003" + type: "implements" + cardinality: "1:1" +--- + +## Description + +ix-cli-core SHALL provide the oclif entry points a consuming CLI needs to run, +so that a binary composed per [FR-010](./FR-010-cli-binary-composition.md) ships +a thin `bin` script rather than re-deriving oclif wiring. The runner is a +wrapper over `@oclif/core`: command discovery and core-plugin discovery are +performed by oclif's own `Config` loader, and ix-cli-core adds no registry, +manifest loader, or plugin resolution of its own. + +`run(argv?, options?)` loads the consuming CLI's `Config` from `options` and +dispatches the requested command, returning the command's result. It SHALL +throw on error rather than calling `process.exit`, so it is safe to drive from +a test. `argv` SHALL default to `process.argv.slice(2)`. + +`execute(options)` is the load-and-run entry point for a top-level bin: it +loads the config, runs the command, flushes output, and owns error handling and +the process exit code. + +`loadConfig(options?)` SHALL expose the resolved plugin and command graph +without dispatching, so a CLI or a test can introspect what oclif discovered. +The returned `Config` SHALL be accepted back by `run` as its `options`, so a +caller resolves the graph at most once. + +`listCorePlugins(config)` SHALL report the core plugins loaded into a config — +those the host declared in `oclif.plugins` and oclif resolved from its +dependencies — excluding the root host plugin itself, for diagnostics and for +asserting plugin-host wiring. + +Only **bundled** core plugins are in scope. ix-cli-core SHALL NOT import +`@oclif/plugin-plugins`; runtime, user-installed plugins are out of scope. + +A `BaseCommand` subclass contributed by either the host or a core plugin SHALL +run unchanged through the runner: its base flags (`--config-root`, +`--no-project-config`) and its capability resolution +([FR-013](./FR-013-per-command-capability-binding.md)) are wired through the +oclif lifecycle, so a command whose required capability is unavailable is +short-circuited before its `run` body executes. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | +| FR-025-AC-1 | A loaded config exposes both the host's own commands and the commands contributed by a declared core plugin | Test | +| FR-025-AC-2 | A host `BaseCommand` subclass runs end-to-end through the runner with its base flags parsed | Test | +| FR-025-AC-3 | A command contributed by a core plugin runs through the runner | Test | +| FR-025-AC-4 | A command whose required capability is unavailable is short-circuited before its `run` body executes | Test | +| FR-025-AC-5 | `run` rejects on a command error rather than calling `process.exit`, so a caller or test observes the failure | Test | +| FR-025-AC-6 | A `Config` obtained from `loadConfig` is accepted by `run` as its `options`, dispatching without re-resolving the graph | Test | +| FR-025-AC-7 | `listCorePlugins` reports declared core plugins and excludes the root host plugin | Test | +| FR-025-AC-8 | ix-cli-core declares no dependency on `@oclif/plugin-plugins` | Inspection | + +## Dependencies + +- **Upstream**: [StR-003](../stakeholder/StR-003-reusable-cli-runtime.md) + (reusable CLI runtime — "no bespoke per-CLI re-implementation"). + Consumes [FR-013](./FR-013-per-command-capability-binding.md) for the + capability wiring the runner inherits. +- **Downstream**: [FR-010](./FR-010-cli-binary-composition.md) — a binary + composed per FR-010 runs on this runner; consuming CLIs (e.g. + `@agent-ix/quoin`) depend on `run` / `loadConfig` being exported. diff --git a/spec/functional/index.md b/spec/functional/index.md index 1ea7225..3b724d6 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -31,3 +31,5 @@ description: "Index of artifacts in this directory." - [FR-021: Bootstrap Into Preferred Agent](./FR-021-bootstrap-into-agent.md) - [FR-022: Preferred-Agent Config and Interactive Chooser](./FR-022-agent-config-chooser.md) - [FR-023: Self-Update Helper](./FR-023-self-update-helper.md) +- [FR-024: Update Notifier](./FR-024-update-notifier.md) +- [FR-025: oclif Runner and Core-Plugin Host](./FR-025-oclif-runner-host.md) diff --git a/spec/tests.md b/spec/tests.md index 20e0033..9fb75ab 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -53,16 +53,17 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | `tests/agent.test.ts` | FR-020, FR-021, FR-022, NFR-007 | | `tests/self-update.test.ts` | FR-023 | | `tests/update-notifier.test.ts` | FR-024 | +| `tests/runner.test.ts` | FR-025 | --- ## Stakeholder Requirement Coverage -| Stakeholder Req | Trace to FR/NFR | Coverage Status | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| StR-001 (pluggable config) | FR-001, FR-002, FR-003, FR-004, FR-008, NFR-003 | ✅ Unit + static | -| StR-002 (secrets never plaintext) | FR-005, FR-006, FR-007, FR-009, NFR-001, NFR-002, NFR-004 | ✅ Unit + static (keyring round-trip via CI matrix) | -| StR-003 (reusable runtime) | FR-010, FR-011, FR-012, FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-020, FR-021, FR-022, FR-023, FR-024 | ⚠️ FR-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) + FR-023 (self-update) + FR-024 (update notifier) unit-covered; FR-010/011/012 BaseCommand wiring covered at host-binary level | +| Stakeholder Req | Trace to FR/NFR | Coverage Status | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| StR-001 (pluggable config) | FR-001, FR-002, FR-003, FR-004, FR-008, NFR-003 | ✅ Unit + static | +| StR-002 (secrets never plaintext) | FR-005, FR-006, FR-007, FR-009, NFR-001, NFR-002, NFR-004 | ✅ Unit + static (keyring round-trip via CI matrix) | +| StR-003 (reusable runtime) | FR-010, FR-011, FR-012, FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-020, FR-021, FR-022, FR-023, FR-024, FR-025 | ⚠️ FR-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) + FR-023 (self-update) + FR-024 (update notifier) + FR-025 (oclif runner) unit-covered; FR-010/011/012 BaseCommand wiring covered at host-binary level | ## User Story Coverage @@ -154,7 +155,7 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | FR-013 | AC-3: optional missing never blocks; resolved surfaced | `capabilities.test.ts` — "does not block on missing optional capabilities" | ✅ Unit | | FR-013 | AC-4: resolver reads through Config/Secrets context | `capabilities.test.ts` — provider context | ✅ Unit | | FR-013 | AC-5: capability errors carry machine-readable code | `capabilities.test.ts` — "preserves provider errors"; `capabilityErrorToJson` | ✅ Unit | -| FR-014 | AC-1..AC-7: ixSchema convention + registration | `plugin-schema.test.ts` — "registerPluginSchema — FR-025 oclif ixSchema convention" | ✅ Unit | +| FR-014 | AC-1..AC-7: ixSchema convention + registration | `plugin-schema.test.ts` — "registerPluginSchema — FR-014 ixSchema plugin convention" | ✅ Unit | | FR-015 | AC-1/AC-2: host normalization (https default, port preserved) | `auth-discovery.test.ts` — "assumes https for a bare host…", "preserves an explicit port" | ✅ Unit | | FR-015 | AC-3: http refused unless dev.ix / insecure | `auth-discovery.test.ts` — "allows http for \*.dev.ix hosts", "rejects http for non-dev.ix hosts unless insecure" | ✅ Unit | | FR-015 | AC-4: GET well-known returns parsed doc | `auth-discovery.test.ts` — "GETs the well-known path and returns the parsed doc" | ✅ Unit | @@ -208,6 +209,14 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | FR-024 | AC-4: newer + decline → `updateAvailable:true, updated:false`, no install | `update-notifier.test.ts` — "reports availability but does not install when the user declines" | ✅ Unit | | FR-024 | AC-5: equal or dev-build-ahead → `updateAvailable:false`, never prompts | `update-notifier.test.ts` — "does not prompt when already on the latest" / "does not prompt a dev build that is ahead of the published version" | ✅ Unit | | FR-024 | AC-6: success/failure records `lastCheck` (throttles next); registry failure → `reason:"error"` | `update-notifier.test.ts` — "records the check in the cache…" / "swallows a registry failure and throttles without breaking the host" | ✅ Unit | +| FR-025 | AC-1: host + core-plugin commands both discovered | `runner.test.ts` — "discovers the host commands AND the core-plugin's commands" | ✅ Unit | +| FR-025 | AC-2: host BaseCommand runs end-to-end with base flags parsed | `runner.test.ts` — "runs a host BaseCommand subclass end-to-end, with base flags parsed" | ✅ Unit | +| FR-025 | AC-3: core-plugin command runs through the runner | `runner.test.ts` — "runs a command contributed by the core plugin via the runner" | ✅ Unit | +| FR-025 | AC-4: unavailable required capability short-circuits before `run` | `runner.test.ts` — "short-circuits a command whose required capability is unavailable" | ✅ Unit | +| FR-025 | AC-5: `run` rejects on error rather than calling `process.exit` | `runner.test.ts` — "rejects on a command error rather than exiting the process" | ✅ Unit | +| FR-025 | AC-6: a `loadConfig` Config is accepted back by `run` as its options | `runner.test.ts` — every dispatch case passes the pre-loaded config into `run` | ✅ Unit | +| FR-025 | AC-7: `listCorePlugins` reports core plugins, excluding the root host | `runner.test.ts` — "lists core plugins without the root host plugin" | ✅ Unit | +| FR-025 | AC-8: no `@oclif/plugin-plugins` dependency | static check (`package.json` declares no `@oclif/plugin-plugins`) | ⚠️ Static | ## Non-Functional Requirement Coverage diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index e037e2f..46d00df 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -62,7 +62,7 @@ export abstract class BaseCommand extends Command { projectConfigEnabled: noProject !== true, }); // Enforce declared capability requirements as part of the oclif lifecycle - // so the runner (FR-015) short-circuits commands whose required + // so the runner (FR-025) short-circuits commands whose required // capabilities are unavailable before `run()` executes. `prerun()` is a // no-op for commands that declare no capabilities. await this.prerun(); diff --git a/src/index.ts b/src/index.ts index 0a1fb51..d3057bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,7 +47,7 @@ export { type ConfigIncident, } from "./config/registry.js"; -// ── ixSchema plugin convention (FR-025 revised) ──────────────────────── +// ── ixSchema plugin convention (FR-014) ─────────────────────────────── export { registerPluginSchema, getRegisteredPluginSchema, @@ -62,7 +62,7 @@ export { export { BaseCommand } from "./commands/base-command.js"; export type { CommandCapabilities } from "./runtime/capability-spec.js"; -// ── oclif runner + core-plugin host (FR-015) ─────────────────────────── +// ── oclif runner + core-plugin host (FR-025) ─────────────────────────── // Lets a consuming CLI (quoin) run BaseCommand subclasses and commands // contributed by packages declared as oclif core plugins via a single // `import { run } from "@agent-ix/ix-cli-core"; run()` entry point. diff --git a/src/plugins/schema.ts b/src/plugins/schema.ts index d71c048..fde2024 100644 --- a/src/plugins/schema.ts +++ b/src/plugins/schema.ts @@ -7,7 +7,7 @@ import type { SecretDeclaration } from "../secrets/types.js"; /** * Convention shape exposed by an IX-compatible plugin from its package - * main as the named export `ixSchema`. See FR-025. + * main as the named export `ixSchema`. See FR-014. * * The host's `init` hook walks the oclif-loaded plugin list, reads each * plugin's `ixSchema`, and registers schemas with `ConfigService` / diff --git a/src/runtime/runner.ts b/src/runtime/runner.ts index 77383c3..46393a6 100644 --- a/src/runtime/runner.ts +++ b/src/runtime/runner.ts @@ -6,7 +6,7 @@ import { } from "@oclif/core"; /** - * oclif runner + core-plugin host for IX CLIs (FR-015). + * oclif runner + core-plugin host for IX CLIs (FR-025). * * A consuming binary (e.g. quoin) ships a thin `bin` script that simply * delegates to this runner: diff --git a/tests/plugin-schema.test.ts b/tests/plugin-schema.test.ts index e643b1d..c9b70df 100644 --- a/tests/plugin-schema.test.ts +++ b/tests/plugin-schema.test.ts @@ -29,7 +29,7 @@ afterEach(() => { _resetPluginSchemaRegistryForTests(); }); -describe("registerPluginSchema — FR-025 oclif ixSchema convention", () => { +describe("registerPluginSchema — FR-014 ixSchema plugin convention", () => { it("registers ixSchema config and secrets into the runtime registries", () => { const result = registerPluginSchema("@agent-ix/workflow-cli-plugin", { id: "workflow", diff --git a/tests/runner.test.ts b/tests/runner.test.ts index 88f4ddc..b7a6a8b 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -153,7 +153,7 @@ afterAll(() => { if (tmp) rmSync(tmp, { recursive: true, force: true }); }); -describe("oclif runner + core-plugin host (FR-015 / TC-015)", () => { +describe("oclif runner + core-plugin host (FR-025)", () => { it("discovers the host commands AND the core-plugin's commands", async () => { const config = await loadConfig({ root: tmp }); @@ -195,4 +195,23 @@ describe("oclif runner + core-plugin host (FR-015 / TC-015)", () => { // must error before its run() body executes. await expect(run(["guarded"], config)).rejects.toThrow(); }); + + it("lists core plugins without the root host plugin", async () => { + const config = await loadConfig({ root: tmp }); + const core = listCorePlugins(config); + + // The host itself is a plugin in oclif's graph; a core-plugin listing that + // included it would misreport the host as one of its own dependencies. + expect(core.map((p) => p.name)).toEqual(["@ixcc-fixture/hello-plugin"]); + expect(core.every((p) => p.type === "core")).toBe(true); + expect(config.plugins.size).toBeGreaterThan(core.length); + }); + + it("rejects on a command error rather than exiting the process", async () => { + const config = await loadConfig({ root: tmp }); + // run() must be safe to drive from a test: a failure surfaces as a + // rejection the caller can catch, never as a process.exit that would take + // the test runner down with it. + await expect(run(["no-such-command"], config)).rejects.toThrow(); + }); });