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
25 changes: 25 additions & 0 deletions packages/core/src/control/action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { action } from './action.js';

describe('action', () => {
it('returns definition as-is with type inference', () => {
const def = action<{ name: string }, { id: string }, { step: number }>({
manifest: {
name: 'test:echo',
version: '1.0',
title: 'Echo',
description: 'Echoes name',
scope: 'machine',
capability: 'test.read',
idempotent: true,
},
async *execute(_ctx, input) {
yield { step: 1 };
return { id: input.name };
},
});

expect(def.manifest.name).toBe('test:echo');
expect(typeof def.execute).toBe('function');
});
});
6 changes: 6 additions & 0 deletions packages/core/src/control/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ActionDefinition } from './types.js';

/** Define a control-plane action. Identity fn — returns config as-is for type inference. */
export const action = <I = unknown, O = unknown, P = unknown>(
def: ActionDefinition<I, O, P>
): ActionDefinition<I, O, P> => def;
179 changes: 179 additions & 0 deletions packages/core/src/control/actions/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from 'vitest';
import { output, result } from '../../events.js';
import { createRegistry } from '../registry.js';
import type { InvokeEvent } from '../types.js';
import { createAgentInstallAction } from './agent-install.js';
import { createCliUpdateAction } from './cli-update.js';
import { createProjectAddFromOriginAction } from './project-add-from-origin.js';
import type { ShellExec } from './types.js';

const fakeShell = (success = true, observe?: (cmd: string) => void): ShellExec =>
async function* (command) {
observe?.(command);
yield output(`running: ${command}`);
yield result(success, success ? 'ok' : 'fail');
};

const collect = async (gen: AsyncGenerator<InvokeEvent>): Promise<InvokeEvent[]> => {
const events: InvokeEvent[] = [];
for await (const e of gen) events.push(e);
return events;
};

describe('cli:update action', () => {
it('validates target as semver or latest', async () => {
const reg = createRegistry();
reg.register(
createCliUpdateAction({ shell: fakeShell(), readCurrentVersion: async () => '0.17.0' })
);
const bad = await collect(
reg.invoke({
action: 'cli:update',
input: { target: 'master' },
callerId: 'c',
capabilities: ['cli.write'],
})
);
expect(bad.at(-1)).toMatchObject({ type: 'error', code: 'INVALID_INPUT' });
});

it('runs happy path with version bump and returns result envelope', async () => {
const observed: string[] = [];
const reg = createRegistry();
reg.register(
createCliUpdateAction({
shell: fakeShell(true, (cmd) => observed.push(cmd)),
readCurrentVersion: async () => '0.17.0',
})
);
const events = await collect(
reg.invoke({
action: 'cli:update',
input: { target: '0.18.0' },
callerId: 'c',
capabilities: ['cli.write'],
})
);
expect(observed).toEqual(['npm install -g @agentage/cli@0.18.0']);
expect(events.at(-1)).toMatchObject({
type: 'result',
data: { installed: '0.18.0', from: '0.17.0' },
});
});

it('surfaces EXECUTION_FAILED when shell fails', async () => {
const reg = createRegistry();
reg.register(
createCliUpdateAction({ shell: fakeShell(false), readCurrentVersion: async () => '0.17.0' })
);
const events = await collect(
reg.invoke({
action: 'cli:update',
input: { target: 'latest' },
callerId: 'c',
capabilities: ['cli.write'],
})
);
expect(events.at(-1)).toMatchObject({ type: 'error', code: 'EXECUTION_FAILED' });
});
});

describe('project:addFromOrigin action', () => {
it('derives name from remote and clones into parentDir', async () => {
const observed: string[] = [];
const reg = createRegistry();
reg.register(
createProjectAddFromOriginAction({ shell: fakeShell(true, (cmd) => observed.push(cmd)) })
);
const events = await collect(
reg.invoke({
action: 'project:addFromOrigin',
input: { remote: 'git@github.com:agentage/cli.git', parentDir: '/tmp/projects' },
callerId: 'c',
capabilities: ['project.write'],
})
);
expect(observed).toEqual(['git clone git@github.com:agentage/cli.git /tmp/projects/cli']);
expect(events.at(-1)).toMatchObject({
type: 'result',
data: { name: 'cli', path: '/tmp/projects/cli', remote: 'git@github.com:agentage/cli.git' },
});
});

it('rejects non-absolute parentDir', async () => {
const reg = createRegistry();
reg.register(createProjectAddFromOriginAction({ shell: fakeShell() }));
const events = await collect(
reg.invoke({
action: 'project:addFromOrigin',
input: { remote: 'git@github.com:a/b.git', parentDir: 'projects' },
callerId: 'c',
capabilities: ['project.write'],
})
);
expect(events.at(-1)).toMatchObject({ type: 'error', code: 'INVALID_INPUT' });
});

it('passes branch flag when provided', async () => {
const spy = vi.fn();
const reg = createRegistry();
reg.register(createProjectAddFromOriginAction({ shell: fakeShell(true, spy) }));
await collect(
reg.invoke({
action: 'project:addFromOrigin',
input: {
remote: 'https://github.com/agentage/cli.git',
parentDir: '/tmp',
branch: 'develop',
},
callerId: 'c',
capabilities: ['project.write'],
})
);
expect(spy).toHaveBeenCalledWith(
'git clone -b develop https://github.com/agentage/cli.git /tmp/cli'
);
});
});

describe('agent:install action', () => {
it('runs npm install with the given spec in workspaceDir', async () => {
const spy = vi.fn();
const reg = createRegistry();
reg.register(createAgentInstallAction({ shell: fakeShell(true, spy) }));
const events = await collect(
reg.invoke({
action: 'agent:install',
input: { spec: '@agentage/agent-pr@1.0.0', workspaceDir: '/home/me/agents' },
callerId: 'c',
capabilities: ['agent.write'],
})
);
expect(spy).toHaveBeenCalledWith('npm install @agentage/agent-pr@1.0.0');
expect(events.at(-1)).toMatchObject({
type: 'result',
data: { spec: '@agentage/agent-pr@1.0.0' },
});
});
});

describe('registry list surface', () => {
it('exposes all three manifests for host-UI discovery', () => {
const reg = createRegistry();
reg.register(
createCliUpdateAction({ shell: fakeShell(), readCurrentVersion: async () => '0.0.0' })
);
reg.register(createProjectAddFromOriginAction({ shell: fakeShell() }));
reg.register(createAgentInstallAction({ shell: fakeShell() }));

const names = reg
.list()
.map((m) => m.name)
.sort();
expect(names).toEqual(['agent:install', 'cli:update', 'project:addFromOrigin']);
for (const m of reg.list()) {
expect(m.capability).toMatch(/\.(read|write)$/);
expect(m.scope).toBe('machine');
}
});
});
60 changes: 60 additions & 0 deletions packages/core/src/control/actions/agent-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { action } from '../action.js';
import { ActionError } from '../errors.js';
import type { ActionDefinition } from '../types.js';
import type { ActionProgress, ShellExec } from './types.js';

export interface AgentInstallInput {
/** npm package spec, e.g. "@agentage/agent-foo@1.2.3" or a directory path */
spec: string;
/** Workspace directory (agents repo root) where the agent should be added */
workspaceDir: string;
}

export interface AgentInstallOutput {
spec: string;
workspaceDir: string;
command: string;
}

const validateAgentInstallInput = (raw: unknown): AgentInstallInput => {
if (!raw || typeof raw !== 'object') throw new Error('input must be an object');
const { spec, workspaceDir } = raw as Record<string, unknown>;
if (typeof spec !== 'string' || spec.length === 0)
throw new Error('spec must be a non-empty string');
if (typeof workspaceDir !== 'string' || !workspaceDir.startsWith('/')) {
throw new Error('workspaceDir must be an absolute path');
}
return { spec, workspaceDir };
};

export const createAgentInstallAction = (deps: {
shell: ShellExec;
}): ActionDefinition<AgentInstallInput, AgentInstallOutput, ActionProgress> =>
action({
manifest: {
name: 'agent:install',
version: '1.0',
title: 'Install agent',
description: 'Install an agent package into the agents workspace',
scope: 'machine',
capability: 'agent.write',
idempotent: false,
},
validateInput: validateAgentInstallInput,
async *execute(ctx, input): AsyncGenerator<ActionProgress, AgentInstallOutput, void> {
const command = `npm install ${input.spec}`;
yield { step: 'install', detail: command };

let failed = false;
for await (const event of deps.shell(command, {
signal: ctx.signal,
cwd: input.workspaceDir,
})) {
if (event.data.type === 'result' && !event.data.success) failed = true;
}
if (failed)
throw new ActionError('EXECUTION_FAILED', `npm install failed: ${input.spec}`, true);

return { spec: input.spec, workspaceDir: input.workspaceDir, command };
},
});
67 changes: 67 additions & 0 deletions packages/core/src/control/actions/cli-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { action } from '../action.js';
import { ActionError } from '../errors.js';
import type { ActionDefinition } from '../types.js';
import type { ActionProgress, ShellExec } from './types.js';

export interface CliUpdateInput {
/** Exact semver to install, or "latest" */
target: string;
/** Package manager used to publish the CLI — currently only npm */
via?: 'npm';
}

export interface CliUpdateOutput {
installed: string;
from: string;
command: string;
}

const SEMVER_OR_LATEST = /^(?:latest|\d+\.\d+\.\d+(?:-[\w.]+)?)$/;

const validateCliUpdateInput = (raw: unknown): CliUpdateInput => {
if (!raw || typeof raw !== 'object') throw new Error('input must be an object');
const { target, via } = raw as { target?: unknown; via?: unknown };
if (typeof target !== 'string' || !SEMVER_OR_LATEST.test(target)) {
throw new Error('target must be "latest" or a semver string like "1.2.3"');
}
if (via !== undefined && via !== 'npm') throw new Error('via must be "npm" when set');
return { target, via: 'npm' };
};

export const createCliUpdateAction = (deps: {
shell: ShellExec;
readCurrentVersion: () => Promise<string>;
}): ActionDefinition<CliUpdateInput, CliUpdateOutput, ActionProgress> =>
action({
manifest: {
name: 'cli:update',
version: '1.0',
title: 'Update CLI',
description: 'Install a specific version of @agentage/cli globally via npm',
scope: 'machine',
capability: 'cli.write',
idempotent: false,
},
validateInput: validateCliUpdateInput,
async *execute(ctx, input): AsyncGenerator<ActionProgress, CliUpdateOutput, void> {
const from = await deps.readCurrentVersion();
yield { step: 'resolve', detail: `current=${from} target=${input.target}` };

const pkg =
input.target === 'latest' ? '@agentage/cli@latest' : `@agentage/cli@${input.target}`;
const command = `npm install -g ${pkg}`;
yield { step: 'install', detail: command };

let lastError: string | undefined;
for await (const event of deps.shell(command, { signal: ctx.signal })) {
if (event.data.type === 'error') {
lastError = `${event.data.code}: ${event.data.message}`;
}
if (event.data.type === 'result' && !event.data.success) {
throw new ActionError('EXECUTION_FAILED', lastError ?? 'npm install failed', true);
}
}

return { installed: input.target, from, command };
},
});
10 changes: 10 additions & 0 deletions packages/core/src/control/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { createCliUpdateAction } from './cli-update.js';
export type { CliUpdateInput, CliUpdateOutput } from './cli-update.js';

export { createProjectAddFromOriginAction } from './project-add-from-origin.js';
export type { ProjectAddInput, ProjectAddOutput } from './project-add-from-origin.js';

export { createAgentInstallAction } from './agent-install.js';
export type { AgentInstallInput, AgentInstallOutput } from './agent-install.js';

export type { ActionProgress, ShellExec } from './types.js';
Loading
Loading