379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import type { Command } from 'commander';
|
|
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
|
|
import { withAuth } from './with-auth.js';
|
|
import { selectItem } from './select-dialog.js';
|
|
import {
|
|
fetchAgentConfigs,
|
|
createAgentConfig,
|
|
updateAgentConfig,
|
|
deleteAgentConfig,
|
|
fetchProjects,
|
|
fetchProviders,
|
|
enrollAgent,
|
|
fetchEnrollment,
|
|
} from '../tui/gateway-api.js';
|
|
import type { AgentConfigInfo, EnrolledAgentInfo } from '../tui/gateway-api.js';
|
|
|
|
function formatAgent(a: AgentConfigInfo): string {
|
|
const sys = a.isSystem ? ' [system]' : '';
|
|
return `${a.name}${sys} — ${a.provider}/${a.model} (${a.status})`;
|
|
}
|
|
|
|
function showAgentDetail(a: AgentConfigInfo) {
|
|
console.log(` ID: ${a.id}`);
|
|
console.log(` Name: ${a.name}`);
|
|
console.log(` Provider: ${a.provider}`);
|
|
console.log(` Model: ${a.model}`);
|
|
console.log(` Status: ${a.status}`);
|
|
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
|
|
console.log(` Project: ${a.projectId ?? '—'}`);
|
|
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
|
|
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
|
|
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
|
|
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
|
|
}
|
|
|
|
export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDeps = {}) {
|
|
const cmd = program
|
|
.command('agent')
|
|
.description('Manage agent configurations and local fleet agents')
|
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
.option('--mosaic-home <path>', 'Mosaic home directory')
|
|
.option('--roster <path>', 'Local fleet roster path')
|
|
.option('--list', 'List all agents')
|
|
.option('--new', 'Create a new agent')
|
|
.option('--show <idOrName>', 'Show agent details')
|
|
.option('--update <idOrName>', 'Update an agent')
|
|
.option('--delete <idOrName>', 'Delete an agent')
|
|
.action(
|
|
async (opts: {
|
|
gateway: string;
|
|
list?: boolean;
|
|
new?: boolean;
|
|
show?: string;
|
|
update?: string;
|
|
delete?: string;
|
|
}) => {
|
|
const auth = await withAuth(opts.gateway);
|
|
|
|
if (opts.list) {
|
|
return listAgents(auth.gateway, auth.cookie);
|
|
}
|
|
if (opts.new) {
|
|
return createAgentWizard(auth.gateway, auth.cookie);
|
|
}
|
|
if (opts.show) {
|
|
return showAgent(auth.gateway, auth.cookie, opts.show);
|
|
}
|
|
if (opts.update) {
|
|
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
|
|
}
|
|
if (opts.delete) {
|
|
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
|
|
}
|
|
|
|
// Default: interactive select
|
|
return interactiveSelect(auth.gateway, auth.cookie);
|
|
},
|
|
);
|
|
|
|
registerEnrollmentCommands(cmd);
|
|
registerFleetAgentCommands(cmd, fleetDeps);
|
|
|
|
return cmd;
|
|
}
|
|
|
|
// ── Agent enrollment (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3;
|
|
// CLI parity bound by contract 5 §4.5) ──
|
|
|
|
export interface EnrollCommandOptions {
|
|
gateway: string;
|
|
harness: string;
|
|
name: string;
|
|
model: string;
|
|
provider: string;
|
|
persona?: string;
|
|
credential: string;
|
|
idempotencyKey?: string;
|
|
correlationId?: string;
|
|
replayMode?: string;
|
|
}
|
|
|
|
/**
|
|
* Read an intake credential value from stdin. Never accepted via argv — a
|
|
* process argument is world-readable in `ps` for the process lifetime.
|
|
*/
|
|
export async function readCredentialFromStdin(): Promise<string> {
|
|
if (process.stdin.isTTY) {
|
|
console.error('Enter API key, then press Enter and Ctrl-D:');
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
return Buffer.concat(chunks)
|
|
.toString('utf8')
|
|
.replace(/\r?\n$/, '');
|
|
}
|
|
|
|
function showEnrollment(correlationId: string, agent: EnrolledAgentInfo): void {
|
|
console.log(` ID: ${agent.id}`);
|
|
console.log(` Name: ${agent.name}`);
|
|
console.log(` Harness: ${agent.harness ?? '—'}`);
|
|
console.log(` Provider: ${agent.provider}`);
|
|
console.log(` Model: ${agent.model}`);
|
|
console.log(` Status: ${agent.status}`);
|
|
console.log(` Owner: ${agent.ownerId ?? '—'}`);
|
|
console.log(` Enrolled: ${agent.enrolledAt ?? '—'}`);
|
|
console.log(` Correlation: ${correlationId}`);
|
|
}
|
|
|
|
export async function runEnroll(
|
|
auth: { gateway: string; cookie: string },
|
|
opts: EnrollCommandOptions,
|
|
readSecret: () => Promise<string> = readCredentialFromStdin,
|
|
): Promise<void> {
|
|
if (opts.credential !== 'reference' && opts.credential !== 'intake') {
|
|
console.error(`Unknown credential mode "${opts.credential}" (use reference or intake).`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string };
|
|
if (opts.credential === 'intake') {
|
|
const value = await readSecret();
|
|
if (!value) {
|
|
console.error('Intake credential requires a non-empty API key on stdin.');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
credential = { mode: 'intake', type: 'api_key', value };
|
|
} else {
|
|
credential = { mode: 'reference' };
|
|
}
|
|
|
|
const result = await enrollAgent(auth.gateway, auth.cookie, {
|
|
harness: opts.harness,
|
|
name: opts.name,
|
|
model: opts.model,
|
|
provider: opts.provider,
|
|
...(opts.persona !== undefined ? { persona: opts.persona } : {}),
|
|
credential,
|
|
idempotencyKey: opts.idempotencyKey ?? randomUUID(),
|
|
...(opts.correlationId !== undefined ? { correlationId: opts.correlationId } : {}),
|
|
...(opts.replayMode !== undefined ? { replayMode: opts.replayMode } : {}),
|
|
});
|
|
|
|
console.log(`Agent "${result.agent.name}" enrolled.\n`);
|
|
showEnrollment(result.correlationId, result.agent);
|
|
}
|
|
|
|
export async function runGetEnrollment(
|
|
auth: { gateway: string; cookie: string },
|
|
agentId: string,
|
|
correlationId?: string,
|
|
): Promise<void> {
|
|
const result = await fetchEnrollment(auth.gateway, auth.cookie, agentId, correlationId);
|
|
showEnrollment(result.correlationId, result.agent);
|
|
}
|
|
|
|
export function registerEnrollmentCommands(cmd: Command): void {
|
|
cmd
|
|
.command('enroll')
|
|
.description('Enroll an agent through the gateway enrollment command (agent.enroll)')
|
|
.requiredOption('--harness <id>', 'Harness the agent runs on (must be registered)')
|
|
.requiredOption('--name <name>', 'Agent display name')
|
|
.requiredOption('--model <model>', 'Model identifier')
|
|
.requiredOption('--provider <provider>', 'Provider the credential belongs to')
|
|
.option('--persona <text>', 'Agent persona / system prompt')
|
|
.option(
|
|
'--credential <mode>',
|
|
'Credential mode: "reference" (already stored) or "intake" (API key read from stdin, never argv)',
|
|
'reference',
|
|
)
|
|
.option('--idempotency-key <uuid>', 'Idempotency key (generated when omitted)')
|
|
.option('--correlation-id <uuid>', 'Correlation id to carry through the audit trail')
|
|
.option('--replay-mode <mode>', 'Idempotency replay mode (actor-bound)')
|
|
.action(async (opts: Omit<EnrollCommandOptions, 'gateway'>) => {
|
|
const parent = cmd.opts<{ gateway: string }>();
|
|
const auth = await withAuth(parent.gateway);
|
|
await runEnroll(auth, { ...opts, gateway: parent.gateway });
|
|
});
|
|
|
|
cmd
|
|
.command('enrollment <agentId>')
|
|
.description('Read one enrolled agent (agent.enrollment.get; owner or admin)')
|
|
.option('--correlation-id <uuid>', 'Correlation id to carry through the read')
|
|
.action(async (agentId: string, opts: { correlationId?: string }) => {
|
|
const parent = cmd.opts<{ gateway: string }>();
|
|
const auth = await withAuth(parent.gateway);
|
|
await runGetEnrollment(auth, agentId, opts.correlationId);
|
|
});
|
|
}
|
|
|
|
async function resolveAgent(
|
|
gateway: string,
|
|
cookie: string,
|
|
idOrName: string,
|
|
): Promise<AgentConfigInfo | undefined> {
|
|
const agents = await fetchAgentConfigs(gateway, cookie);
|
|
return agents.find((a) => a.id === idOrName || a.name === idOrName);
|
|
}
|
|
|
|
async function listAgents(gateway: string, cookie: string) {
|
|
const agents = await fetchAgentConfigs(gateway, cookie);
|
|
if (agents.length === 0) {
|
|
console.log('No agents found.');
|
|
return;
|
|
}
|
|
console.log(`Agents (${agents.length}):\n`);
|
|
for (const a of agents) {
|
|
const sys = a.isSystem ? ' [system]' : '';
|
|
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
|
|
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
|
|
}
|
|
}
|
|
|
|
async function showAgent(gateway: string, cookie: string, idOrName: string) {
|
|
const agent = await resolveAgent(gateway, cookie, idOrName);
|
|
if (!agent) {
|
|
console.error(`Agent "${idOrName}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
showAgentDetail(agent);
|
|
}
|
|
|
|
async function interactiveSelect(gateway: string, cookie: string) {
|
|
const agents = await fetchAgentConfigs(gateway, cookie);
|
|
const selected = await selectItem(agents, {
|
|
message: 'Select an agent:',
|
|
render: formatAgent,
|
|
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
|
|
});
|
|
if (selected) {
|
|
showAgentDetail(selected);
|
|
}
|
|
}
|
|
|
|
async function createAgentWizard(gateway: string, cookie: string) {
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
|
|
try {
|
|
const name = await ask('Agent name: ');
|
|
if (!name.trim()) {
|
|
console.error('Name is required.');
|
|
return;
|
|
}
|
|
|
|
// Project selection
|
|
const projects = await fetchProjects(gateway, cookie);
|
|
let projectId: string | undefined;
|
|
if (projects.length > 0) {
|
|
const selected = await selectItem(projects, {
|
|
message: 'Assign to project (optional):',
|
|
render: (p) => `${p.name} (${p.status})`,
|
|
});
|
|
if (selected) projectId = selected.id;
|
|
}
|
|
|
|
// Provider / model selection
|
|
const providers = await fetchProviders(gateway, cookie);
|
|
let provider = 'default';
|
|
let model = 'default';
|
|
|
|
if (providers.length > 0) {
|
|
const allModels = providers.flatMap((p) =>
|
|
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
|
|
);
|
|
if (allModels.length > 0) {
|
|
const selected = await selectItem(allModels, {
|
|
message: 'Select model:',
|
|
render: (m) => m.label,
|
|
});
|
|
if (selected) {
|
|
provider = selected.provider;
|
|
model = selected.model;
|
|
}
|
|
}
|
|
}
|
|
|
|
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
|
|
|
|
const agent = await createAgentConfig(gateway, cookie, {
|
|
name: name.trim(),
|
|
provider,
|
|
model,
|
|
projectId,
|
|
systemPrompt: systemPrompt.trim() || undefined,
|
|
});
|
|
|
|
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
|
|
const agent = await resolveAgent(gateway, cookie, idOrName);
|
|
if (!agent) {
|
|
console.error(`Agent "${idOrName}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
|
|
try {
|
|
console.log(`Updating agent: ${agent.name}\n`);
|
|
|
|
const name = await ask(`Name [${agent.name}]: `);
|
|
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
|
|
|
|
const updates: Record<string, unknown> = {};
|
|
if (name.trim()) updates['name'] = name.trim();
|
|
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
console.log('No changes.');
|
|
return;
|
|
}
|
|
|
|
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
|
|
console.log(`\nAgent "${updated.name}" updated.`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
|
|
const agent = await resolveAgent(gateway, cookie, idOrName);
|
|
if (!agent) {
|
|
console.error(`Agent "${idOrName}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (agent.isSystem) {
|
|
console.error('Cannot delete system agents.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const answer = await new Promise<string>((resolve) =>
|
|
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
|
|
);
|
|
rl.close();
|
|
|
|
if (answer.toLowerCase() !== 'y') {
|
|
console.log('Cancelled.');
|
|
return;
|
|
}
|
|
|
|
await deleteAgentConfig(gateway, cookie, agent.id);
|
|
console.log(`Agent "${agent.name}" deleted.`);
|
|
}
|