Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
115 changes: 115 additions & 0 deletions src/runtime/runner.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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<unknown> {
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<Config> {
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],
}));
}
198 changes: 198 additions & 0 deletions tests/runner.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading