316 lines
9.8 KiB
TypeScript
316 lines
9.8 KiB
TypeScript
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 },
|
|
);
|
|
}
|