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
6 changes: 4 additions & 2 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ on:
jobs:
ci:
uses: agent-ix/nodejs-actions/.github/workflows/build-test-monorepo.yml@main
secrets:
NPM_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
with:
npm_registry: https://registry.npmjs.org
trusted_publish: true
node_version: "22" # OIDC Trusted Publishing requires Node >= 22.14
7 changes: 2 additions & 5 deletions .github/workflows/keyring-platform-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,12 @@ jobs:
node-version: "22"
cache: "pnpm"

- name: Configure npm auth for @agent-ix
- name: Configure npm registry for @agent-ix
shell: bash
env:
NPM_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
{
echo "registry=https://registry.npmjs.org/"
echo "@agent-ix:registry=https://npm.pkg.github.com"
echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}"
echo "@agent-ix:registry=https://registry.npmjs.org/"
} > .npmrc

- name: Install dependencies
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ permissions:
jobs:
ci:
uses: agent-ix/nodejs-actions/.github/workflows/release-monorepo.yml@main
secrets:
NPM_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
with:
npm_registry: https://registry.npmjs.org
trusted_publish: true
node_version: "22" # OIDC Trusted Publishing requires Node >= 22.14
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

13 changes: 4 additions & 9 deletions apps/ix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "@agent-ix/ix",
"description": "The Agent IX unified CLI binary.",
"version": "0.6.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"bin": {
"ix": "./bin/ix.js"
Expand All @@ -15,10 +16,7 @@
"bin": "ix",
"dirname": "ix",
"commands": "./dist/commands",
"plugins": [
"@agent-ix/workflow-cli-plugin",
"@agent-ix/workflow-definitions"
],
"plugins": [],
"hooks": {
"init": "./dist/hooks/init"
},
Expand Down Expand Up @@ -61,13 +59,10 @@
"clean": "rm -rf dist coverage oclif.manifest.json"
},
"dependencies": {
"@agent-ix/ix-cli-core": ">=0.10.3",
"@agent-ix/ix-cli-core": ">=0.11.0",
"@agent-ix/ix-cli-elements": "workspace:*",
"@agent-ix/ix-cli-local": "workspace:*",
"@agent-ix/ix-ui-cli": ">=0.4.6",
"@agent-ix/workflow-cli-plugin": ">=0.1.4",
"@agent-ix/workflow-core": ">=0.1.4",
"@agent-ix/workflow-definitions": ">=0.1.4",
"@agent-ix/ix-ui-cli": ">=0.4.10",
"@oclif/core": ">=4.10.6",
"picocolors": ">=1.1.1",
"react": ">=18.3.1",
Expand Down
40 changes: 4 additions & 36 deletions apps/ix/src/hooks/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import {
type SecretsBackend,
type SecretsBackendMode,
} from "@agent-ix/ix-cli-core";
import { registerWorkflowPlugin } from "@agent-ix/workflow-cli-plugin";
import type { WorkflowPlugin } from "@agent-ix/workflow-core";
import {
LocalConfigSchema,
LocalEnvBindings,
Expand Down Expand Up @@ -108,30 +106,6 @@ const hook: Hook<"init"> = async function ({ config }) {
);
}
}

// FR-010: also collect `workflowPlugin` exports into the
// workflow-cli-plugin process-scope registry. FR-010-AC-3 makes
// `ixSchema` mandatory for any workflow contributor — a plugin
// that ships `workflowPlugin` without `ixSchema` is rejected
// (warn-and-skip, consistent with the rest of init).
const workflowPlugin = (mod as { workflowPlugin?: WorkflowPlugin })
.workflowPlugin;
if (workflowPlugin) {
if (!ixSchema) {
this.warn(
`${plugin.name} exports workflowPlugin but is missing ixSchema; ` +
`FR-010-AC-3 requires both. Skipping workflow registration.`,
);
} else {
try {
registerWorkflowPlugin(plugin.name, workflowPlugin);
} catch (regErr) {
this.warn(
`${plugin.name} workflowPlugin registration failed: ${regErr instanceof Error ? regErr.message : String(regErr)}`,
);
}
}
}
}

// ── SecretsService default ─────────────────────────────────────────
Expand Down Expand Up @@ -175,22 +149,16 @@ async function loadPluginMain(plugin: {
if (hasIxExports(imported)) return imported;
} catch {
// Some oclif plugins do not expose importable package mains. Those
// remain valid plugins; they simply have no IX config/secrets schema
// or workflow contributions.
// remain valid plugins; they simply have no IX config/secrets schema.
}

return loaded;
}

function hasIxExports(
value: unknown,
): value is { ixSchema?: IxPluginSchema; workflowPlugin?: WorkflowPlugin } {
function hasIxExports(value: unknown): value is { ixSchema?: IxPluginSchema } {
if (typeof value !== "object" || value === null) return false;
const obj = value as {
ixSchema?: IxPluginSchema;
workflowPlugin?: WorkflowPlugin;
};
return Boolean(obj.ixSchema) || Boolean(obj.workflowPlugin);
const obj = value as { ixSchema?: IxPluginSchema };
return Boolean(obj.ixSchema);
}

export default hook;
135 changes: 23 additions & 112 deletions apps/ix/tests/init-hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
/**
* Init-hook coverage for workflow-plugin discovery (FR-010-AC-1/AC-3).
* Init-hook coverage for the ixSchema plugin walk (FR-025 revised).
*
* Drives `src/hooks/init.ts` directly with a synthetic oclif `config`
* object so we can assert what gets registered without booting the
* whole CLI. Covers:
* object so we can assert how the plugin walk behaves without booting the
* whole CLI. Workflow discovery was removed from ix-cli (moved to ix-flow),
* so this only exercises the schema-registration walk:
*
* - happy path: a plugin exporting `{ ixSchema, workflowPlugin }` is
* accepted; the contribution is visible in `getRegisteredWorkflowPlugins`.
* - FR-010-AC-3: a plugin exporting `workflowPlugin` WITHOUT `ixSchema`
* is warn-and-skipped (workflow does not appear in the registry).
* - FR-010 / warn-and-skip: a plugin whose `load()` rejects is
* warn-and-skipped; other plugins still register.
* - workflow_name_conflict: a duplicate `def.name` across two plugins
* surfaces as a warning from the hook (init continues).
* - a plugin exporting `ixSchema` is accepted without warnings.
* - a plugin whose `load()` rejects is warn-and-skipped; other plugins
* still process.
* - a plugin exporting neither `ixSchema` is ignored silently.
*/

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { parseWorkflowDef, type WorkflowPlugin } from "@agent-ix/workflow-core";
import {
clearRegisteredWorkflowPlugins,
getRegisteredWorkflowPlugins,
} from "@agent-ix/workflow-cli-plugin";

import hook, { _resetInitGuardForTests } from "../src/hooks/init.js";

Expand Down Expand Up @@ -52,27 +44,8 @@ function hookContext(): HookContext {
};
}

const helloDef = parseWorkflowDef({
name: "hello-init",
version: "0.1.0",
initialPhase: "start",
phases: [{ name: "start" }, { name: "done", terminal: true }],
transitions: [
{ from: "start", to: "done", invariants: [], defaultGate: "auto" },
],
itemSchemas: {},
linkSchemas: {},
});

const helloPlugin: WorkflowPlugin = {
workflows: [{ def: helloDef, invariants: {} }],
};

const ixSchemaStub = { id: "init-hook-test" };

async function runHook(plugins: FakePlugin[]): Promise<HookContext> {
_resetInitGuardForTests();
clearRegisteredWorkflowPlugins();
const ctx = hookContext();
// The hook narrows `this` to oclif's Hook context; the only member it
// touches on `this` is `warn`, so a bare object is sufficient.
Expand All @@ -87,51 +60,27 @@ async function runHook(plugins: FakePlugin[]): Promise<HookContext> {

beforeEach(() => {
_resetInitGuardForTests();
clearRegisteredWorkflowPlugins();
});

afterEach(() => {
_resetInitGuardForTests();
clearRegisteredWorkflowPlugins();
});

describe("init hook — workflowPlugin discovery (FR-010)", () => {
it("registers a workflowPlugin contribution when ixSchema is also present (FR-010-AC-1)", async () => {
const ctx = await runHook([
{
name: "@test/good-plugin",
load: async () => ({
ixSchema: ixSchemaStub,
workflowPlugin: helloPlugin,
}),
},
]);

const registered = getRegisteredWorkflowPlugins();
expect(registered).toHaveLength(1);
expect(registered[0].source).toBe("@test/good-plugin");
expect(registered[0].plugin.workflows[0].def.name).toBe("hello-init");
// No warning about this plugin.
expect(ctx.warnings.filter((w) => w.includes("@test/good-plugin"))).toEqual(
[],
);
});

it("rejects workflowPlugin without ixSchema with a warning (FR-010-AC-3)", async () => {
describe("init hook — ixSchema plugin walk (FR-025)", () => {
// NOTE: the plugin-schema registry is process-global and not reset between
// tests, so each test uses a unique plugin name to avoid spurious
// duplicate-registration warnings.
it("accepts a plugin exporting ixSchema without warnings", async () => {
const ctx = await runHook([
{
name: "@test/missing-ix-schema",
load: async () => ({ workflowPlugin: helloPlugin }),
name: "@test/ix-schema-ok",
load: async () => ({ ixSchema: { id: "init-hook-ok" } }),
},
]);

expect(getRegisteredWorkflowPlugins()).toHaveLength(0);
expect(
ctx.warnings.some(
(w) =>
w.includes("@test/missing-ix-schema") && w.includes("FR-010-AC-3"),
),
).toBe(true);
ctx.warnings.filter((w) => w.includes("@test/ix-schema-ok")),
).toEqual([]);
});

it("warn-and-skips a plugin whose load() throws and continues with the rest", async () => {
Expand All @@ -143,68 +92,30 @@ describe("init hook — workflowPlugin discovery (FR-010)", () => {
},
},
{
name: "@test/good-plugin",
load: async () => ({
ixSchema: ixSchemaStub,
workflowPlugin: helloPlugin,
}),
name: "@test/good-after-broken",
load: async () => ({ ixSchema: { id: "init-hook-after-broken" } }),
},
]);

const registered = getRegisteredWorkflowPlugins();
expect(registered).toHaveLength(1);
expect(registered[0].source).toBe("@test/good-plugin");
expect(
ctx.warnings.some(
(w) => w.includes("@test/broken-plugin") && w.includes("kaboom"),
),
).toBe(true);
});

it("warns when two plugins contribute the same workflow name (FR-010 errors)", async () => {
const ctx = await runHook([
{
name: "@test/first",
load: async () => ({
ixSchema: ixSchemaStub,
workflowPlugin: helloPlugin,
}),
},
{
name: "@test/second",
load: async () => ({
ixSchema: ixSchemaStub,
workflowPlugin: helloPlugin,
}),
},
]);

// The first plugin registers; the second's duplicate name triggers
// a warning from `registerWorkflowPlugin` -> hook.warn path. We
// accept either order of register/skip — the contract is that the
// hook does not abort and at least one is registered.
const registered = getRegisteredWorkflowPlugins();
expect(registered.length).toBeGreaterThanOrEqual(1);
// The good plugin after the broken one still processed without warning.
expect(
ctx.warnings.some(
(w) =>
(w.includes("@test/second") || w.includes("@test/first")) &&
/workflowPlugin registration failed|workflow_name_conflict|hello-init/.test(
w,
),
),
).toBe(true);
ctx.warnings.filter((w) => w.includes("@test/good-after-broken")),
).toEqual([]);
});

it("ignores plugins exporting neither ixSchema nor workflowPlugin", async () => {
it("ignores plugins exporting neither ixSchema", async () => {
const ctx = await runHook([
{
name: "@test/inert-plugin",
load: async () => ({}),
},
]);

expect(getRegisteredWorkflowPlugins()).toHaveLength(0);
expect(
ctx.warnings.filter((w) => w.includes("@test/inert-plugin")),
).toEqual([]);
Expand Down
15 changes: 7 additions & 8 deletions apps/ix/tests/static-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
* - bin/ix.js does NOT preprocess argv (FR-022 revised).
* - The init hook walks Config.plugins for ixSchema (FR-025 revised),
* not a custom IxPlugin registry.
* - apps/ix/package.json lists @agent-ix/workflow-cli-plugin in
* oclif.plugins instead of registering it through the legacy
* distribution module.
* - Workflow functionality is no longer part of ix-cli (moved to ix-flow);
* apps/ix ships no workflow commands and bundles no workflow plugins.
*/

import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -84,16 +83,16 @@ describe("shared CLI output style", () => {
});

describe("oclif-native plugin architecture (FR-021/022/025 revised)", () => {
it("workflow commands live in the workflow-cli-plugin, not apps/ix", () => {
it("workflow functionality is not in apps/ix (moved to ix-flow)", () => {
expect(existsSync(join(SRC_ROOT, "commands/workflow"))).toBe(false);
expect(existsSync(join(SRC_ROOT, "workflow.ts"))).toBe(false);
});

it("apps/ix/package.json lists workflow-cli-plugin in oclif.plugins", () => {
const pkg = JSON.parse(
readFileSync(join(APP_ROOT, "package.json"), "utf-8"),
);
expect(pkg.oclif.plugins).toContain("@agent-ix/workflow-cli-plugin");
expect(pkg.oclif.plugins).not.toContain("@agent-ix/workflow-cli-plugin");
expect(JSON.stringify(pkg.dependencies)).not.toContain(
"@agent-ix/workflow",
);
});

it("bin/ix.js does NOT preprocess argv for --config-root", () => {
Expand Down
1 change: 0 additions & 1 deletion apps/ix/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export default defineConfig({
"@agent-ix/ix-cli-core",
"@agent-ix/ix-cli-elements",
"@agent-ix/ix-ui-cli",
"@agent-ix/workflow-cli-plugin",
"@oclif/core",
"picocolors",
/^node:/,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "@agent-ix/ix-cli",
"description": "Unified CLI for the Agent IX ecosystem",
"version": "0.6.0",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.4",
Expand Down
Loading
Loading