import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; import { removeMaintainerOnlySkillSymlinks, resolvePaperclipSkillsDir, } from "@paperclipai/adapter-utils/server-utils"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { addCommonClientOptions, formatInlineRecord, handleCommandError, printOutput, resolveCommandContext, type BaseClientOptions, } from "./common.js"; interface AgentListOptions extends BaseClientOptions { companyId?: string; } interface AgentLocalCliOptions extends BaseClientOptions { companyId?: string; keyName?: string; installSkills?: boolean; } interface CreatedAgentKey { id: string; name: string; token: string; createdAt: string; } interface SkillsInstallSummary { tool: "codex" | "claude"; target: string; linked: string[]; removed: string[]; skipped: string[]; failed: Array<{ name: string; error: string }>; } const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); function codexSkillsHome(): string { const fromEnv = process.env.CODEX_HOME?.trim(); const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); return path.join(base, "skills"); } function claudeSkillsHome(): string { const fromEnv = process.env.CLAUDE_HOME?.trim(); const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude"); return path.join(base, "skills"); } async function installSkillsForTarget( sourceSkillsDir: string, targetSkillsDir: string, tool: "codex" | "claude", ): Promise { const summary: SkillsInstallSummary = { tool, target: targetSkillsDir, linked: [], removed: [], skipped: [], failed: [], }; await fs.mkdir(targetSkillsDir, { recursive: true }); const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); summary.removed = await removeMaintainerOnlySkillSymlinks( targetSkillsDir, entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), ); for (const entry of entries) { if (!entry.isDirectory()) continue; const source = path.join(sourceSkillsDir, entry.name); const target = path.join(targetSkillsDir, entry.name); const existing = await fs.lstat(target).catch(() => null); if (existing) { if (existing.isSymbolicLink()) { let linkedPath: string | null = null; try { linkedPath = await fs.readlink(target); } catch (err) { await fs.unlink(target); try { await fs.symlink(source, target); summary.linked.push(entry.name); continue; } catch (linkErr) { summary.failed.push({ name: entry.name, error: err instanceof Error && linkErr instanceof Error ? `${err.message}; then ${linkErr.message}` : err instanceof Error ? err.message : `Failed to recover broken symlink: ${String(err)}`, }); continue; } } const resolvedLinkedPath = path.isAbsolute(linkedPath) ? linkedPath : path.resolve(path.dirname(target), linkedPath); const linkedTargetExists = await fs .stat(resolvedLinkedPath) .then(() => true) .catch(() => false); if (!linkedTargetExists) { await fs.unlink(target); } else { summary.skipped.push(entry.name); continue; } } else { summary.skipped.push(entry.name); continue; } } try { await fs.symlink(source, target); summary.linked.push(entry.name); } catch (err) { summary.failed.push({ name: entry.name, error: err instanceof Error ? err.message : String(err), }); } } return summary; } function buildAgentEnvExports(input: { apiBase: string; companyId: string; agentId: string; apiKey: string; }): string { const escaped = (value: string) => value.replace(/'/g, "'\"'\"'"); return [ `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`, `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`, `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`, `export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`, ].join("\n"); } export function registerAgentCommands(program: Command): void { const agent = program.command("agent").description("Agent operations"); addCommonClientOptions( agent .command("list") .description("List agents for a company") .requiredOption("-C, --company-id ", "Company ID") .action(async (opts: AgentListOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const rows = (await ctx.api.get(`/api/companies/${ctx.companyId}/agents`)) ?? []; if (ctx.json) { printOutput(rows, { json: true }); return; } if (rows.length === 0) { printOutput([], { json: false }); return; } for (const row of rows) { console.log( formatInlineRecord({ id: row.id, name: row.name, role: row.role, status: row.status, reportsTo: row.reportsTo, budgetMonthlyCents: row.budgetMonthlyCents, spentMonthlyCents: row.spentMonthlyCents, }), ); } } catch (err) { handleCommandError(err); } }), { includeCompany: false }, ); addCommonClientOptions( agent .command("get") .description("Get one agent") .argument("", "Agent ID") .action(async (agentId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); const row = await ctx.api.get(`/api/agents/${agentId}`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); } }), ); addCommonClientOptions( agent .command("local-cli") .description( "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports", ) .argument("", "Agent ID or shortname/url-key") .requiredOption("-C, --company-id ", "Company ID") .option("--key-name ", "API key label", "local-cli") .option( "--no-install-skills", "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills", ) .action(async (agentRef: string, opts: AgentLocalCliOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); const agentRow = await ctx.api.get( `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, ); if (!agentRow) { throw new Error(`Agent not found: ${agentRef}`); } const now = new Date().toISOString().replaceAll(":", "-"); const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); if (!key) { throw new Error("Failed to create API key"); } const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]); if (!skillsDir) { throw new Error( "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", ); } installSummaries.push( await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), ); } const exportsText = buildAgentEnvExports({ apiBase: ctx.api.apiBase, companyId: agentRow.companyId, agentId: agentRow.id, apiKey: key.token, }); if (ctx.json) { printOutput( { agent: { id: agentRow.id, name: agentRow.name, urlKey: agentRow.urlKey, companyId: agentRow.companyId, }, key: { id: key.id, name: key.name, createdAt: key.createdAt, token: key.token, }, skills: installSummaries, exports: exportsText, }, { json: true }, ); return; } console.log(`Agent: ${agentRow.name} (${agentRow.id})`); console.log(`API key created: ${key.name} (${key.id})`); if (installSummaries.length > 0) { for (const summary of installSummaries) { console.log( `${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, ); for (const failed of summary.failed) { console.log(` failed ${failed.name}: ${failed.error}`); } } } console.log(""); console.log("# Run this in your shell before launching codex/claude:"); console.log(exportsText); } catch (err) { handleCommandError(err); } }), { includeCompany: false }, ); }