offwatch 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/bin/offwatch.js +7 -6
  2. package/package.json +4 -6
  3. package/src/__tests__/agent-jwt-env.test.ts +79 -0
  4. package/src/__tests__/allowed-hostname.test.ts +80 -0
  5. package/src/__tests__/auth-command-registration.test.ts +16 -0
  6. package/src/__tests__/board-auth.test.ts +53 -0
  7. package/src/__tests__/common.test.ts +98 -0
  8. package/src/__tests__/company-delete.test.ts +95 -0
  9. package/src/__tests__/company-import-export-e2e.test.ts +502 -0
  10. package/src/__tests__/company-import-url.test.ts +74 -0
  11. package/src/__tests__/company-import-zip.test.ts +44 -0
  12. package/src/__tests__/company.test.ts +599 -0
  13. package/src/__tests__/context.test.ts +70 -0
  14. package/src/__tests__/data-dir.test.ts +79 -0
  15. package/src/__tests__/doctor.test.ts +102 -0
  16. package/src/__tests__/feedback.test.ts +177 -0
  17. package/src/__tests__/helpers/embedded-postgres.ts +6 -0
  18. package/src/__tests__/helpers/zip.ts +87 -0
  19. package/src/__tests__/home-paths.test.ts +44 -0
  20. package/src/__tests__/http.test.ts +106 -0
  21. package/src/__tests__/network-bind.test.ts +62 -0
  22. package/src/__tests__/onboard.test.ts +166 -0
  23. package/src/__tests__/routines.test.ts +249 -0
  24. package/src/__tests__/telemetry.test.ts +117 -0
  25. package/src/__tests__/worktree-merge-history.test.ts +492 -0
  26. package/src/__tests__/worktree.test.ts +982 -0
  27. package/src/adapters/http/format-event.ts +4 -0
  28. package/src/adapters/http/index.ts +7 -0
  29. package/src/adapters/index.ts +2 -0
  30. package/src/adapters/process/format-event.ts +4 -0
  31. package/src/adapters/process/index.ts +7 -0
  32. package/src/adapters/registry.ts +63 -0
  33. package/src/checks/agent-jwt-secret-check.ts +40 -0
  34. package/src/checks/config-check.ts +33 -0
  35. package/src/checks/database-check.ts +59 -0
  36. package/src/checks/deployment-auth-check.ts +88 -0
  37. package/src/checks/index.ts +18 -0
  38. package/src/checks/llm-check.ts +82 -0
  39. package/src/checks/log-check.ts +30 -0
  40. package/src/checks/path-resolver.ts +1 -0
  41. package/src/checks/port-check.ts +24 -0
  42. package/src/checks/secrets-check.ts +146 -0
  43. package/src/checks/storage-check.ts +51 -0
  44. package/src/client/board-auth.ts +282 -0
  45. package/src/client/command-label.ts +4 -0
  46. package/src/client/context.ts +175 -0
  47. package/src/client/http.ts +255 -0
  48. package/src/commands/allowed-hostname.ts +40 -0
  49. package/src/commands/auth-bootstrap-ceo.ts +138 -0
  50. package/src/commands/client/activity.ts +71 -0
  51. package/src/commands/client/agent.ts +315 -0
  52. package/src/commands/client/approval.ts +259 -0
  53. package/src/commands/client/auth.ts +113 -0
  54. package/src/commands/client/common.ts +221 -0
  55. package/src/commands/client/company.ts +1578 -0
  56. package/src/commands/client/context.ts +125 -0
  57. package/src/commands/client/dashboard.ts +34 -0
  58. package/src/commands/client/feedback.ts +645 -0
  59. package/src/commands/client/issue.ts +411 -0
  60. package/src/commands/client/plugin.ts +374 -0
  61. package/src/commands/client/zip.ts +129 -0
  62. package/src/commands/configure.ts +201 -0
  63. package/src/commands/db-backup.ts +102 -0
  64. package/src/commands/doctor.ts +203 -0
  65. package/src/commands/env.ts +411 -0
  66. package/src/commands/heartbeat-run.ts +344 -0
  67. package/src/commands/onboard.ts +692 -0
  68. package/src/commands/routines.ts +352 -0
  69. package/src/commands/run.ts +216 -0
  70. package/src/commands/worktree-lib.ts +279 -0
  71. package/src/commands/worktree-merge-history-lib.ts +764 -0
  72. package/src/commands/worktree.ts +2876 -0
  73. package/src/config/data-dir.ts +48 -0
  74. package/src/config/env.ts +125 -0
  75. package/src/config/home.ts +80 -0
  76. package/src/config/hostnames.ts +26 -0
  77. package/src/config/schema.ts +30 -0
  78. package/src/config/secrets-key.ts +48 -0
  79. package/src/config/server-bind.ts +183 -0
  80. package/src/config/store.ts +120 -0
  81. package/src/index.ts +182 -0
  82. package/src/prompts/database.ts +157 -0
  83. package/src/prompts/llm.ts +43 -0
  84. package/src/prompts/logging.ts +37 -0
  85. package/src/prompts/secrets.ts +99 -0
  86. package/src/prompts/server.ts +221 -0
  87. package/src/prompts/storage.ts +146 -0
  88. package/src/telemetry.ts +49 -0
  89. package/src/utils/banner.ts +24 -0
  90. package/src/utils/net.ts +18 -0
  91. package/src/utils/path-resolver.ts +25 -0
  92. package/src/version.ts +10 -0
  93. package/lib/downloader.js +0 -112
  94. package/postinstall.js +0 -23
@@ -0,0 +1,40 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { normalizeHostnameInput } from "../config/hostnames.js";
4
+ import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
5
+
6
+ export async function addAllowedHostname(host: string, opts: { config?: string }): Promise<void> {
7
+ const configPath = resolveConfigPath(opts.config);
8
+ const config = readConfig(opts.config);
9
+
10
+ if (!config) {
11
+ p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
12
+ return;
13
+ }
14
+
15
+ const normalized = normalizeHostnameInput(host);
16
+ const current = new Set((config.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean));
17
+ const existed = current.has(normalized);
18
+ current.add(normalized);
19
+
20
+ config.server.allowedHostnames = Array.from(current).sort();
21
+ config.$meta.updatedAt = new Date().toISOString();
22
+ config.$meta.source = "configure";
23
+ writeConfig(config, opts.config);
24
+
25
+ if (existed) {
26
+ p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
27
+ } else {
28
+ p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
29
+ p.log.message(
30
+ pc.dim("Restart the Paperclip server for this change to take effect."),
31
+ );
32
+ }
33
+
34
+ if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
35
+ p.log.message(
36
+ pc.dim("Note: allowed hostnames are enforced only in authenticated/private mode."),
37
+ );
38
+ }
39
+ }
40
+
@@ -0,0 +1,138 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { and, eq, gt, isNull } from "drizzle-orm";
5
+ import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
6
+ import { inferBindModeFromHost } from "@paperclipai/shared";
7
+ import { loadPaperclipEnvFile } from "../config/env.js";
8
+ import { readConfig, resolveConfigPath } from "../config/store.js";
9
+
10
+ function hashToken(token: string) {
11
+ return createHash("sha256").update(token).digest("hex");
12
+ }
13
+
14
+ function createInviteToken() {
15
+ return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
16
+ }
17
+
18
+ function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
19
+ if (explicitDbUrl) return explicitDbUrl;
20
+ const config = readConfig(configPath);
21
+ if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
22
+ if (config?.database.mode === "postgres" && config.database.connectionString) {
23
+ return config.database.connectionString;
24
+ }
25
+ if (config?.database.mode === "embedded-postgres") {
26
+ const port = config.database.embeddedPostgresPort ?? 54329;
27
+ return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
33
+ if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
34
+ const fromEnv =
35
+ process.env.PAPERCLIP_PUBLIC_URL ??
36
+ process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
37
+ process.env.BETTER_AUTH_URL ??
38
+ process.env.BETTER_AUTH_BASE_URL;
39
+ if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
40
+ const config = readConfig(configPath);
41
+ if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
42
+ return config.auth.publicBaseUrl.replace(/\/+$/, "");
43
+ }
44
+ const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
45
+ const host =
46
+ bind === "custom"
47
+ ? config?.server.customBindHost ?? config?.server.host ?? "localhost"
48
+ : config?.server.host ?? "localhost";
49
+ const port = config?.server.port ?? 3100;
50
+ const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
51
+ return `http://${publicHost}:${port}`;
52
+ }
53
+
54
+ export async function bootstrapCeoInvite(opts: {
55
+ config?: string;
56
+ force?: boolean;
57
+ expiresHours?: number;
58
+ baseUrl?: string;
59
+ dbUrl?: string;
60
+ }) {
61
+ const configPath = resolveConfigPath(opts.config);
62
+ loadPaperclipEnvFile(configPath);
63
+ const config = readConfig(configPath);
64
+ if (!config) {
65
+ p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
66
+ return;
67
+ }
68
+
69
+ if (config.server.deploymentMode !== "authenticated") {
70
+ p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
71
+ return;
72
+ }
73
+
74
+ const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
75
+ if (!dbUrl) {
76
+ p.log.error(
77
+ "Could not resolve database connection for bootstrap.",
78
+ );
79
+ return;
80
+ }
81
+
82
+ const db = createDb(dbUrl);
83
+ const closableDb = db as typeof db & {
84
+ $client?: {
85
+ end?: (options?: { timeout?: number }) => Promise<void>;
86
+ };
87
+ };
88
+ try {
89
+ const existingAdminCount = await db
90
+ .select()
91
+ .from(instanceUserRoles)
92
+ .where(eq(instanceUserRoles.role, "instance_admin"))
93
+ .then((rows) => rows.length);
94
+
95
+ if (existingAdminCount > 0 && !opts.force) {
96
+ p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite.");
97
+ return;
98
+ }
99
+
100
+ const now = new Date();
101
+ await db
102
+ .update(invites)
103
+ .set({ revokedAt: now, updatedAt: now })
104
+ .where(
105
+ and(
106
+ eq(invites.inviteType, "bootstrap_ceo"),
107
+ isNull(invites.revokedAt),
108
+ isNull(invites.acceptedAt),
109
+ gt(invites.expiresAt, now),
110
+ ),
111
+ );
112
+
113
+ const token = createInviteToken();
114
+ const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72));
115
+ const created = await db
116
+ .insert(invites)
117
+ .values({
118
+ inviteType: "bootstrap_ceo",
119
+ tokenHash: hashToken(token),
120
+ allowedJoinTypes: "human",
121
+ expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000),
122
+ invitedByUserId: "system",
123
+ })
124
+ .returning()
125
+ .then((rows) => rows[0]);
126
+
127
+ const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
128
+ const inviteUrl = `${baseUrl}/invite/${token}`;
129
+ p.log.success("Created bootstrap CEO invite.");
130
+ p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
131
+ p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
132
+ } catch (err) {
133
+ p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
134
+ p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
135
+ } finally {
136
+ await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
137
+ }
138
+ }
@@ -0,0 +1,71 @@
1
+ import { Command } from "commander";
2
+ import type { ActivityEvent } from "@paperclipai/shared";
3
+ import {
4
+ addCommonClientOptions,
5
+ formatInlineRecord,
6
+ handleCommandError,
7
+ printOutput,
8
+ resolveCommandContext,
9
+ type BaseClientOptions,
10
+ } from "./common.js";
11
+
12
+ interface ActivityListOptions extends BaseClientOptions {
13
+ companyId?: string;
14
+ agentId?: string;
15
+ entityType?: string;
16
+ entityId?: string;
17
+ }
18
+
19
+ export function registerActivityCommands(program: Command): void {
20
+ const activity = program.command("activity").description("Activity log operations");
21
+
22
+ addCommonClientOptions(
23
+ activity
24
+ .command("list")
25
+ .description("List company activity log entries")
26
+ .requiredOption("-C, --company-id <id>", "Company ID")
27
+ .option("--agent-id <id>", "Filter by agent ID")
28
+ .option("--entity-type <type>", "Filter by entity type")
29
+ .option("--entity-id <id>", "Filter by entity ID")
30
+ .action(async (opts: ActivityListOptions) => {
31
+ try {
32
+ const ctx = resolveCommandContext(opts, { requireCompany: true });
33
+ const params = new URLSearchParams();
34
+ if (opts.agentId) params.set("agentId", opts.agentId);
35
+ if (opts.entityType) params.set("entityType", opts.entityType);
36
+ if (opts.entityId) params.set("entityId", opts.entityId);
37
+
38
+ const query = params.toString();
39
+ const path = `/api/companies/${ctx.companyId}/activity${query ? `?${query}` : ""}`;
40
+ const rows = (await ctx.api.get<ActivityEvent[]>(path)) ?? [];
41
+
42
+ if (ctx.json) {
43
+ printOutput(rows, { json: true });
44
+ return;
45
+ }
46
+
47
+ if (rows.length === 0) {
48
+ printOutput([], { json: false });
49
+ return;
50
+ }
51
+
52
+ for (const row of rows) {
53
+ console.log(
54
+ formatInlineRecord({
55
+ id: row.id,
56
+ action: row.action,
57
+ actorType: row.actorType,
58
+ actorId: row.actorId,
59
+ entityType: row.entityType,
60
+ entityId: row.entityId,
61
+ createdAt: String(row.createdAt),
62
+ }),
63
+ );
64
+ }
65
+ } catch (err) {
66
+ handleCommandError(err);
67
+ }
68
+ }),
69
+ { includeCompany: false },
70
+ );
71
+ }
@@ -0,0 +1,315 @@
1
+ import { Command } from "commander";
2
+ import type { Agent } from "@paperclipai/shared";
3
+ import {
4
+ removeMaintainerOnlySkillSymlinks,
5
+ resolvePaperclipSkillsDir,
6
+ } from "@paperclipai/adapter-utils/server-utils";
7
+ import fs from "node:fs/promises";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import {
12
+ addCommonClientOptions,
13
+ formatInlineRecord,
14
+ handleCommandError,
15
+ printOutput,
16
+ resolveCommandContext,
17
+ type BaseClientOptions,
18
+ } from "./common.js";
19
+
20
+ interface AgentListOptions extends BaseClientOptions {
21
+ companyId?: string;
22
+ }
23
+
24
+ interface AgentLocalCliOptions extends BaseClientOptions {
25
+ companyId?: string;
26
+ keyName?: string;
27
+ installSkills?: boolean;
28
+ }
29
+
30
+ interface CreatedAgentKey {
31
+ id: string;
32
+ name: string;
33
+ token: string;
34
+ createdAt: string;
35
+ }
36
+
37
+ interface SkillsInstallSummary {
38
+ tool: "codex" | "claude";
39
+ target: string;
40
+ linked: string[];
41
+ removed: string[];
42
+ skipped: string[];
43
+ failed: Array<{ name: string; error: string }>;
44
+ }
45
+
46
+ const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
47
+
48
+ function codexSkillsHome(): string {
49
+ const fromEnv = process.env.CODEX_HOME?.trim();
50
+ const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
51
+ return path.join(base, "skills");
52
+ }
53
+
54
+ function claudeSkillsHome(): string {
55
+ const fromEnv = process.env.CLAUDE_HOME?.trim();
56
+ const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
57
+ return path.join(base, "skills");
58
+ }
59
+
60
+ async function installSkillsForTarget(
61
+ sourceSkillsDir: string,
62
+ targetSkillsDir: string,
63
+ tool: "codex" | "claude",
64
+ ): Promise<SkillsInstallSummary> {
65
+ const summary: SkillsInstallSummary = {
66
+ tool,
67
+ target: targetSkillsDir,
68
+ linked: [],
69
+ removed: [],
70
+ skipped: [],
71
+ failed: [],
72
+ };
73
+
74
+ await fs.mkdir(targetSkillsDir, { recursive: true });
75
+ const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
76
+ summary.removed = await removeMaintainerOnlySkillSymlinks(
77
+ targetSkillsDir,
78
+ entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
79
+ );
80
+ for (const entry of entries) {
81
+ if (!entry.isDirectory()) continue;
82
+ const source = path.join(sourceSkillsDir, entry.name);
83
+ const target = path.join(targetSkillsDir, entry.name);
84
+ const existing = await fs.lstat(target).catch(() => null);
85
+ if (existing) {
86
+ if (existing.isSymbolicLink()) {
87
+ let linkedPath: string | null = null;
88
+ try {
89
+ linkedPath = await fs.readlink(target);
90
+ } catch (err) {
91
+ await fs.unlink(target);
92
+ try {
93
+ await fs.symlink(source, target);
94
+ summary.linked.push(entry.name);
95
+ continue;
96
+ } catch (linkErr) {
97
+ summary.failed.push({
98
+ name: entry.name,
99
+ error:
100
+ err instanceof Error && linkErr instanceof Error
101
+ ? `${err.message}; then ${linkErr.message}`
102
+ : err instanceof Error
103
+ ? err.message
104
+ : `Failed to recover broken symlink: ${String(err)}`,
105
+ });
106
+ continue;
107
+ }
108
+ }
109
+
110
+ const resolvedLinkedPath = path.isAbsolute(linkedPath)
111
+ ? linkedPath
112
+ : path.resolve(path.dirname(target), linkedPath);
113
+ const linkedTargetExists = await fs
114
+ .stat(resolvedLinkedPath)
115
+ .then(() => true)
116
+ .catch(() => false);
117
+
118
+ if (!linkedTargetExists) {
119
+ await fs.unlink(target);
120
+ } else {
121
+ summary.skipped.push(entry.name);
122
+ continue;
123
+ }
124
+ } else {
125
+ summary.skipped.push(entry.name);
126
+ continue;
127
+ }
128
+ }
129
+
130
+ try {
131
+ await fs.symlink(source, target);
132
+ summary.linked.push(entry.name);
133
+ } catch (err) {
134
+ summary.failed.push({
135
+ name: entry.name,
136
+ error: err instanceof Error ? err.message : String(err),
137
+ });
138
+ }
139
+ }
140
+
141
+ return summary;
142
+ }
143
+
144
+ function buildAgentEnvExports(input: {
145
+ apiBase: string;
146
+ companyId: string;
147
+ agentId: string;
148
+ apiKey: string;
149
+ }): string {
150
+ const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
151
+ return [
152
+ `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
153
+ `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
154
+ `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
155
+ `export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
156
+ ].join("\n");
157
+ }
158
+
159
+ export function registerAgentCommands(program: Command): void {
160
+ const agent = program.command("agent").description("Agent operations");
161
+
162
+ addCommonClientOptions(
163
+ agent
164
+ .command("list")
165
+ .description("List agents for a company")
166
+ .requiredOption("-C, --company-id <id>", "Company ID")
167
+ .action(async (opts: AgentListOptions) => {
168
+ try {
169
+ const ctx = resolveCommandContext(opts, { requireCompany: true });
170
+ const rows = (await ctx.api.get<Agent[]>(`/api/companies/${ctx.companyId}/agents`)) ?? [];
171
+
172
+ if (ctx.json) {
173
+ printOutput(rows, { json: true });
174
+ return;
175
+ }
176
+
177
+ if (rows.length === 0) {
178
+ printOutput([], { json: false });
179
+ return;
180
+ }
181
+
182
+ for (const row of rows) {
183
+ console.log(
184
+ formatInlineRecord({
185
+ id: row.id,
186
+ name: row.name,
187
+ role: row.role,
188
+ status: row.status,
189
+ reportsTo: row.reportsTo,
190
+ budgetMonthlyCents: row.budgetMonthlyCents,
191
+ spentMonthlyCents: row.spentMonthlyCents,
192
+ }),
193
+ );
194
+ }
195
+ } catch (err) {
196
+ handleCommandError(err);
197
+ }
198
+ }),
199
+ { includeCompany: false },
200
+ );
201
+
202
+ addCommonClientOptions(
203
+ agent
204
+ .command("get")
205
+ .description("Get one agent")
206
+ .argument("<agentId>", "Agent ID")
207
+ .action(async (agentId: string, opts: BaseClientOptions) => {
208
+ try {
209
+ const ctx = resolveCommandContext(opts);
210
+ const row = await ctx.api.get<Agent>(`/api/agents/${agentId}`);
211
+ printOutput(row, { json: ctx.json });
212
+ } catch (err) {
213
+ handleCommandError(err);
214
+ }
215
+ }),
216
+ );
217
+
218
+ addCommonClientOptions(
219
+ agent
220
+ .command("local-cli")
221
+ .description(
222
+ "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
223
+ )
224
+ .argument("<agentRef>", "Agent ID or shortname/url-key")
225
+ .requiredOption("-C, --company-id <id>", "Company ID")
226
+ .option("--key-name <name>", "API key label", "local-cli")
227
+ .option(
228
+ "--no-install-skills",
229
+ "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
230
+ )
231
+ .action(async (agentRef: string, opts: AgentLocalCliOptions) => {
232
+ try {
233
+ const ctx = resolveCommandContext(opts, { requireCompany: true });
234
+ const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
235
+ const agentRow = await ctx.api.get<Agent>(
236
+ `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
237
+ );
238
+ if (!agentRow) {
239
+ throw new Error(`Agent not found: ${agentRef}`);
240
+ }
241
+
242
+ const now = new Date().toISOString().replaceAll(":", "-");
243
+ const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
244
+ const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
245
+ if (!key) {
246
+ throw new Error("Failed to create API key");
247
+ }
248
+
249
+ const installSummaries: SkillsInstallSummary[] = [];
250
+ if (opts.installSkills !== false) {
251
+ const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
252
+ if (!skillsDir) {
253
+ throw new Error(
254
+ "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
255
+ );
256
+ }
257
+
258
+ installSummaries.push(
259
+ await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
260
+ await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
261
+ );
262
+ }
263
+
264
+ const exportsText = buildAgentEnvExports({
265
+ apiBase: ctx.api.apiBase,
266
+ companyId: agentRow.companyId,
267
+ agentId: agentRow.id,
268
+ apiKey: key.token,
269
+ });
270
+
271
+ if (ctx.json) {
272
+ printOutput(
273
+ {
274
+ agent: {
275
+ id: agentRow.id,
276
+ name: agentRow.name,
277
+ urlKey: agentRow.urlKey,
278
+ companyId: agentRow.companyId,
279
+ },
280
+ key: {
281
+ id: key.id,
282
+ name: key.name,
283
+ createdAt: key.createdAt,
284
+ token: key.token,
285
+ },
286
+ skills: installSummaries,
287
+ exports: exportsText,
288
+ },
289
+ { json: true },
290
+ );
291
+ return;
292
+ }
293
+
294
+ console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
295
+ console.log(`API key created: ${key.name} (${key.id})`);
296
+ if (installSummaries.length > 0) {
297
+ for (const summary of installSummaries) {
298
+ console.log(
299
+ `${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
300
+ );
301
+ for (const failed of summary.failed) {
302
+ console.log(` failed ${failed.name}: ${failed.error}`);
303
+ }
304
+ }
305
+ }
306
+ console.log("");
307
+ console.log("# Run this in your shell before launching codex/claude:");
308
+ console.log(exportsText);
309
+ } catch (err) {
310
+ handleCommandError(err);
311
+ }
312
+ }),
313
+ { includeCompany: false },
314
+ );
315
+ }