11import { Command } from "commander" ;
22import type { Agent } from "@paperclipai/shared" ;
3+ import fs from "node:fs/promises" ;
4+ import os from "node:os" ;
5+ import path from "node:path" ;
6+ import { fileURLToPath } from "node:url" ;
37import {
48 addCommonClientOptions ,
59 formatInlineRecord ,
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
1317 companyId ?: string ;
1418}
1519
20+ interface AgentLocalCliOptions extends BaseClientOptions {
21+ companyId ?: string ;
22+ keyName ?: string ;
23+ installSkills ?: boolean ;
24+ }
25+
26+ interface CreatedAgentKey {
27+ id : string ;
28+ name : string ;
29+ token : string ;
30+ createdAt : string ;
31+ }
32+
33+ interface SkillsInstallSummary {
34+ tool : "codex" | "claude" ;
35+ target : string ;
36+ linked : string [ ] ;
37+ skipped : string [ ] ;
38+ failed : Array < { name : string ; error : string } > ;
39+ }
40+
41+ const __moduleDir = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
42+ const PAPERCLIP_SKILLS_CANDIDATES = [
43+ path . resolve ( __moduleDir , "../../../../../skills" ) , // dev: cli/src/commands/client -> repo root/skills
44+ path . resolve ( process . cwd ( ) , "skills" ) ,
45+ ] ;
46+
47+ function codexSkillsHome ( ) : string {
48+ const fromEnv = process . env . CODEX_HOME ?. trim ( ) ;
49+ const base = fromEnv && fromEnv . length > 0 ? fromEnv : path . join ( os . homedir ( ) , ".codex" ) ;
50+ return path . join ( base , "skills" ) ;
51+ }
52+
53+ function claudeSkillsHome ( ) : string {
54+ const fromEnv = process . env . CLAUDE_HOME ?. trim ( ) ;
55+ const base = fromEnv && fromEnv . length > 0 ? fromEnv : path . join ( os . homedir ( ) , ".claude" ) ;
56+ return path . join ( base , "skills" ) ;
57+ }
58+
59+ async function resolvePaperclipSkillsDir ( ) : Promise < string | null > {
60+ for ( const candidate of PAPERCLIP_SKILLS_CANDIDATES ) {
61+ const isDir = await fs . stat ( candidate ) . then ( ( s ) => s . isDirectory ( ) ) . catch ( ( ) => false ) ;
62+ if ( isDir ) return candidate ;
63+ }
64+ return null ;
65+ }
66+
67+ async function installSkillsForTarget (
68+ sourceSkillsDir : string ,
69+ targetSkillsDir : string ,
70+ tool : "codex" | "claude" ,
71+ ) : Promise < SkillsInstallSummary > {
72+ const summary : SkillsInstallSummary = {
73+ tool,
74+ target : targetSkillsDir ,
75+ linked : [ ] ,
76+ skipped : [ ] ,
77+ failed : [ ] ,
78+ } ;
79+
80+ await fs . mkdir ( targetSkillsDir , { recursive : true } ) ;
81+ const entries = await fs . readdir ( sourceSkillsDir , { withFileTypes : true } ) ;
82+ for ( const entry of entries ) {
83+ if ( ! entry . isDirectory ( ) ) continue ;
84+ const source = path . join ( sourceSkillsDir , entry . name ) ;
85+ const target = path . join ( targetSkillsDir , entry . name ) ;
86+ const existing = await fs . lstat ( target ) . catch ( ( ) => null ) ;
87+ if ( existing ) {
88+ summary . skipped . push ( entry . name ) ;
89+ continue ;
90+ }
91+
92+ try {
93+ await fs . symlink ( source , target ) ;
94+ summary . linked . push ( entry . name ) ;
95+ } catch ( err ) {
96+ summary . failed . push ( {
97+ name : entry . name ,
98+ error : err instanceof Error ? err . message : String ( err ) ,
99+ } ) ;
100+ }
101+ }
102+
103+ return summary ;
104+ }
105+
106+ function buildAgentEnvExports ( input : {
107+ apiBase : string ;
108+ companyId : string ;
109+ agentId : string ;
110+ apiKey : string ;
111+ } ) : string {
112+ const escaped = ( value : string ) => value . replace ( / ' / g, "'\"'\"'" ) ;
113+ return [
114+ `export PAPERCLIP_API_URL='${ escaped ( input . apiBase ) } '` ,
115+ `export PAPERCLIP_COMPANY_ID='${ escaped ( input . companyId ) } '` ,
116+ `export PAPERCLIP_AGENT_ID='${ escaped ( input . agentId ) } '` ,
117+ `export PAPERCLIP_API_KEY='${ escaped ( input . apiKey ) } '` ,
118+ ] . join ( "\n" ) ;
119+ }
120+
16121export function registerAgentCommands ( program : Command ) : void {
17122 const agent = program . command ( "agent" ) . description ( "Agent operations" ) ;
18123
@@ -71,4 +176,96 @@ export function registerAgentCommands(program: Command): void {
71176 }
72177 } ) ,
73178 ) ;
179+
180+ addCommonClientOptions (
181+ agent
182+ . command ( "local-cli" )
183+ . description (
184+ "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports" ,
185+ )
186+ . argument ( "<agentRef>" , "Agent ID or shortname/url-key" )
187+ . requiredOption ( "-C, --company-id <id>" , "Company ID" )
188+ . option ( "--key-name <name>" , "API key label" , "local-cli" )
189+ . option (
190+ "--no-install-skills" ,
191+ "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills" ,
192+ )
193+ . action ( async ( agentRef : string , opts : AgentLocalCliOptions ) => {
194+ try {
195+ const ctx = resolveCommandContext ( opts , { requireCompany : true } ) ;
196+ const query = new URLSearchParams ( { companyId : ctx . companyId ?? "" } ) ;
197+ const agentRow = await ctx . api . get < Agent > (
198+ `/api/agents/${ encodeURIComponent ( agentRef ) } ?${ query . toString ( ) } ` ,
199+ ) ;
200+
201+ const now = new Date ( ) . toISOString ( ) . replaceAll ( ":" , "-" ) ;
202+ const keyName = opts . keyName ?. trim ( ) ? opts . keyName . trim ( ) : `local-cli-${ now } ` ;
203+ const key = await ctx . api . post < CreatedAgentKey > ( `/api/agents/${ agentRow . id } /keys` , { name : keyName } ) ;
204+
205+ const installSummaries : SkillsInstallSummary [ ] = [ ] ;
206+ if ( opts . installSkills !== false ) {
207+ const skillsDir = await resolvePaperclipSkillsDir ( ) ;
208+ if ( ! skillsDir ) {
209+ throw new Error (
210+ "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout." ,
211+ ) ;
212+ }
213+
214+ installSummaries . push (
215+ await installSkillsForTarget ( skillsDir , codexSkillsHome ( ) , "codex" ) ,
216+ await installSkillsForTarget ( skillsDir , claudeSkillsHome ( ) , "claude" ) ,
217+ ) ;
218+ }
219+
220+ const exportsText = buildAgentEnvExports ( {
221+ apiBase : ctx . api . apiBase ,
222+ companyId : agentRow . companyId ,
223+ agentId : agentRow . id ,
224+ apiKey : key . token ,
225+ } ) ;
226+
227+ if ( ctx . json ) {
228+ printOutput (
229+ {
230+ agent : {
231+ id : agentRow . id ,
232+ name : agentRow . name ,
233+ urlKey : agentRow . urlKey ,
234+ companyId : agentRow . companyId ,
235+ } ,
236+ key : {
237+ id : key . id ,
238+ name : key . name ,
239+ createdAt : key . createdAt ,
240+ token : key . token ,
241+ } ,
242+ skills : installSummaries ,
243+ exports : exportsText ,
244+ } ,
245+ { json : true } ,
246+ ) ;
247+ return ;
248+ }
249+
250+ console . log ( `Agent: ${ agentRow . name } (${ agentRow . id } )` ) ;
251+ console . log ( `API key created: ${ key . name } (${ key . id } )` ) ;
252+ if ( installSummaries . length > 0 ) {
253+ for ( const summary of installSummaries ) {
254+ console . log (
255+ `${ summary . tool } : linked=${ summary . linked . length } skipped=${ summary . skipped . length } failed=${ summary . failed . length } target=${ summary . target } ` ,
256+ ) ;
257+ for ( const failed of summary . failed ) {
258+ console . log ( ` failed ${ failed . name } : ${ failed . error } ` ) ;
259+ }
260+ }
261+ }
262+ console . log ( "" ) ;
263+ console . log ( "# Run this in your shell before launching codex/claude:" ) ;
264+ console . log ( exportsText ) ;
265+ } catch ( err ) {
266+ handleCommandError ( err ) ;
267+ }
268+ } ) ,
269+ { includeCompany : false } ,
270+ ) ;
74271}
0 commit comments