| 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<SkillsInstallSummary> { |
| 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 <id>", "Company ID") |
| .action(async (opts: AgentListOptions) => { |
| try { |
| const ctx = resolveCommandContext(opts, { requireCompany: true }); |
| const rows = (await ctx.api.get<Agent[]>(`/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("<agentId>", "Agent ID") |
| .action(async (agentId: string, opts: BaseClientOptions) => { |
| try { |
| const ctx = resolveCommandContext(opts); |
| const row = await ctx.api.get<Agent>(`/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("<agentRef>", "Agent ID or shortname/url-key") |
| .requiredOption("-C, --company-id <id>", "Company ID") |
| .option("--key-name <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<Agent>( |
| `/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<CreatedAgentKey>(`/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 }, |
| ); |
| } |
|
|