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:
@@ -1,5 +1,9 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
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 {
|
import {
|
||||||
addCommonClientOptions,
|
addCommonClientOptions,
|
||||||
formatInlineRecord,
|
formatInlineRecord,
|
||||||
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
|
|||||||
companyId?: string;
|
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 {
|
export function registerAgentCommands(program: Command): void {
|
||||||
const agent = program.command("agent").description("Agent operations");
|
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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
doc/CLI.md
14
doc/CLI.md
@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
|
|||||||
```sh
|
```sh
|
||||||
pnpm paperclipai agent list --company-id <company-id>
|
pnpm paperclipai agent list --company-id <company-id>
|
||||||
pnpm paperclipai agent get <agent-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
|
## Approval Commands
|
||||||
|
|||||||
@@ -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.
|
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
|
## Environment Test
|
||||||
|
|
||||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||||
|
|||||||
@@ -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.
|
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
|
## Environment Test
|
||||||
|
|
||||||
The environment test checks:
|
The environment test checks:
|
||||||
|
|||||||
Reference in New Issue
Block a user