Skip to content
Merged
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
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions spec/functional/FR-025-oclif-runner-host.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions spec/functional/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
21 changes: 15 additions & 6 deletions spec/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

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

public async prerun(): Promise<void> {
Expand Down
15 changes: 14 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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-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.
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
2 changes: 1 addition & 1 deletion src/plugins/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
Loading