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
98 changes: 83 additions & 15 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ program.showHelpAfterError();
addWorkspaceOption(
program
.command('init [path]')
.description('Initialize a pure workgraph workspace (no memory category scaffolding)')
.description('Initialize or repair a workgraph workspace starter kit')
.option('-n, --name <name>', 'Workspace name')
.option('--no-type-dirs', 'Do not pre-create built-in type directories')
.option('--no-bases', 'Do not generate .base files from primitive registry')
.option('--no-readme', 'Do not create README.md')
.option('--no-readme', 'Do not create README.md/QUICKSTART.md')
.option('--json', 'Emit structured JSON output')
).action((targetPath, opts) =>
runCommand(
Expand All @@ -70,12 +70,27 @@ addWorkspaceOption(
});
return result;
},
(result) => [
`Initialized workgraph workspace: ${result.workspacePath}`,
`Seeded types: ${result.seededTypes.join(', ')}`,
`Generated .base files: ${result.generatedBases.length}`,
`Config: ${result.configPath}`,
]
(result) => {
const roleSeeded = result.starterKit.roles.created.length + result.starterKit.roles.existing.length;
const policySeeded = result.starterKit.policies.created.length + result.starterKit.policies.existing.length;
const gateSeeded = result.starterKit.gates.created.length + result.starterKit.gates.existing.length;
const spaceSeeded = result.starterKit.spaces.created.length + result.starterKit.spaces.existing.length;
return [
`${result.alreadyInitialized ? 'Updated' : 'Initialized'} workgraph workspace: ${result.workspacePath}`,
`Seeded types: ${result.seededTypes.join(', ')}`,
`Generated .base files: ${result.generatedBases.length}`,
`Config: ${result.configPath}`,
`Server config: ${result.serverConfigPath}`,
`Starter kit primitives: roles=${roleSeeded} policies=${policySeeded} gates=${gateSeeded} spaces=${spaceSeeded}`,
`Bootstrap trust token (${result.bootstrapTrustTokenPath}): ${result.bootstrapTrustToken}`,
...(result.quickstartPath ? [`Quickstart: ${result.quickstartPath}`] : []),
'',
'Next steps:',
`1) Start server: workgraph serve -w "${result.workspacePath}"`,
`2) Register first agent: workgraph agent register agent-1 -w "${result.workspacePath}" --token ${result.bootstrapTrustToken}`,
`3) Create first thread: workgraph thread create "First coordinated task" -w "${result.workspacePath}" --goal "Validate onboarding flow" --actor agent-1`,
];
}
)
);

Expand Down Expand Up @@ -495,6 +510,46 @@ addWorkspaceOption(
)
);

addWorkspaceOption(
agentCmd
.command('register <name>')
.description('Register an agent using the bootstrap trust token')
.option('--token <token>', 'Bootstrap trust token (or WORKGRAPH_TRUST_TOKEN env)')
.option('--role <role>', 'Role slug/path override (default from trust token)')
.option('--capabilities <items>', 'Comma-separated extra capabilities')
.option('--status <status>', 'online | busy | offline', 'online')
.option('--current-task <threadRef>', 'Optional current task/thread ref')
.option('-a, --actor <name>', 'Actor writing registration artifacts')
.option('--json', 'Emit structured JSON output')
).action((name, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
const token = String(opts.token ?? process.env.WORKGRAPH_TRUST_TOKEN ?? '').trim();
if (!token) {
throw new Error('Missing trust token. Provide --token or set WORKGRAPH_TRUST_TOKEN.');
}
return workgraph.agent.registerAgent(workspacePath, name, {
token,
role: opts.role,
capabilities: csv(opts.capabilities),
status: normalizeAgentPresenceStatus(opts.status),
currentTask: opts.currentTask,
actor: opts.actor,
});
},
(result) => [
`Registered agent: ${result.agentName}`,
`Role: ${result.role} (${result.rolePath})`,
`Capabilities: ${result.capabilities.join(', ') || 'none'}`,
`Presence: ${result.presence.path}`,
`Policy party: ${result.policyParty.id}`,
`Bootstrap token: ${result.trustTokenPath} [${result.trustTokenStatus}]`,
],
)
);

addWorkspaceOption(
agentCmd
.command('list')
Expand Down Expand Up @@ -2112,20 +2167,33 @@ addWorkspaceOption(
program
.command('serve')
.description('Serve Workgraph HTTP MCP server + REST API')
.option('--port <port>', 'HTTP port (default: 8787)')
.option('--host <host>', 'Bind host (default: 0.0.0.0)')
.option('--port <port>', 'HTTP port (defaults to server config or 8787)')
.option('--host <host>', 'Bind host (defaults to server config or 0.0.0.0)')
.option('--token <token>', 'Optional bearer token for MCP + REST auth')
.option('-a, --actor <name>', 'Default actor for thread mutations', DEFAULT_ACTOR),
.option('-a, --actor <name>', 'Default actor for thread mutations'),
).action(async (opts) => {
const workspacePath = resolveWorkspacePath(opts);
const port = opts.port !== undefined ? parsePortOption(opts.port) : 8787;
const host = opts.host ? String(opts.host) : '0.0.0.0';
const serverConfig = workgraph.serverConfig.loadServerConfig(workspacePath);
const port = opts.port !== undefined
? parsePortOption(opts.port)
: (serverConfig?.port ?? 8787);
const host = opts.host
? String(opts.host)
: (serverConfig?.host ?? '0.0.0.0');
const defaultActor = opts.actor
? String(opts.actor)
: (serverConfig?.defaultActor ?? DEFAULT_ACTOR);
const endpointPath = serverConfig?.endpointPath;
const bearerToken = opts.token
? String(opts.token)
: serverConfig?.bearerToken;
const handle = await startWorkgraphServer({
workspacePath,
host,
port,
bearerToken: opts.token ? String(opts.token) : undefined,
defaultActor: opts.actor,
endpointPath,
bearerToken,
defaultActor,
});
console.log(`Server URL: ${handle.baseUrl}`);
console.log(`MCP endpoint: ${handle.url}`);
Expand Down
59 changes: 59 additions & 0 deletions packages/kernel/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import path from 'node:path';
import os from 'node:os';
import { loadRegistry, saveRegistry } from './registry.js';
import * as agent from './agent.js';
import { getParty } from './policy.js';
import * as store from './store.js';
import { initWorkspace } from './workspace.js';

let workspacePath: string;

Expand Down Expand Up @@ -74,4 +76,61 @@ describe('agent presence', () => {
expect(listed[0].path).toBe(beta.path);
expect(listed[1].path).toBe(alpha.path);
});

it('registers first agent from bootstrap trust token and creates policy party', () => {
const initResult = initWorkspace(workspacePath, { createReadme: false });

const registration = agent.registerAgent(workspacePath, 'agent-alpha', {
token: initResult.bootstrapTrustToken,
});

expect(registration.agentName).toBe('agent-alpha');
expect(registration.role).toBe('admin');
expect(registration.rolePath).toBe('roles/admin.md');
expect(registration.trustTokenPath).toBe(initResult.bootstrapTrustTokenPath);
expect(registration.trustTokenStatus).toBe('used');
expect(registration.policyParty.id).toBe('agent-alpha');
expect(registration.policyParty.roles).toEqual(['admin']);
expect(registration.policyParty.capabilities).toContain('agent:register');
expect(registration.presence.path).toBe('agents/agent-alpha.md');

const persistedParty = getParty(workspacePath, 'agent-alpha');
expect(persistedParty?.roles).toEqual(['admin']);
expect(persistedParty?.capabilities).toContain('promote:sensitive');

const trustToken = store.read(workspacePath, initResult.bootstrapTrustTokenPath);
expect(trustToken).not.toBeNull();
expect(trustToken?.fields.status).toBe('used');
expect(trustToken?.fields.used_by).toEqual(['agent-alpha']);
expect(trustToken?.fields.used_count).toBe(1);
});

it('allows idempotent re-registration for the same agent token holder', () => {
const initResult = initWorkspace(workspacePath, { createReadme: false });

const first = agent.registerAgent(workspacePath, 'agent-alpha', {
token: initResult.bootstrapTrustToken,
});
const second = agent.registerAgent(workspacePath, 'agent-alpha', {
token: initResult.bootstrapTrustToken,
});

expect(first.agentName).toBe('agent-alpha');
expect(second.agentName).toBe('agent-alpha');
const trustToken = store.read(workspacePath, initResult.bootstrapTrustTokenPath);
expect(trustToken?.fields.used_by).toEqual(['agent-alpha']);
expect(trustToken?.fields.used_count).toBe(1);
});

it('rejects registering a second distinct agent with single-use bootstrap token', () => {
const initResult = initWorkspace(workspacePath, { createReadme: false });

agent.registerAgent(workspacePath, 'agent-alpha', {
token: initResult.bootstrapTrustToken,
});

expect(() => agent.registerAgent(workspacePath, 'agent-beta', {
token: initResult.bootstrapTrustToken,
})).toThrow('already been used');
});
});
Loading
Loading