feat(cli): add agent local-cli command for skill install and env export

New subcommand to install Paperclip skills for Claude/Codex agents and
print the required PAPERCLIP_* environment variables for local CLI
usage outside heartbeat runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-07 08:59:45 -06:00
parent f1ad727f8e
commit 654463c28f
4 changed files with 227 additions and 0 deletions

View File

@@ -1,5 +1,9 @@
import { Command } from "commander";
import type { Agent } from "@paperclipai/shared";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
formatInlineRecord,
@@ -13,6 +17,107 @@ 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[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
path.resolve(process.cwd(), "skills"),
];
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 resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: "codex" | "claude",
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
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) {
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");
@@ -71,4 +176,96 @@ export function registerAgentCommands(program: Command): void {
}
}),
);
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()}`,
);
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 });
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir();
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} 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 },
);
}

View File

@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
```sh
pnpm paperclipai agent list --company-id <company-id>
pnpm paperclipai agent get <agent-id>
pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
```
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
- creates a new long-lived agent API key
- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills`
- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`
Example for shortname-based local setup:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
## Approval Commands

View File

@@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
```sh
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
## Environment Test
Use the "Test Environment" button in the UI to validate the adapter config. It checks:

View File

@@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
```
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
## Environment Test
The environment test checks: