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(); + }); +});