diff --git a/packages/cli/package.json b/packages/cli/package.json index fac1998..5cdc327 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@clack/prompts": "^0.10.0", + "commander": "^12.0.0", "picocolors": "^1.1.0" }, "devDependencies": { diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index eb1a290..ba416ee 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -1,32 +1,66 @@ import { resolve } from 'node:path'; import * as p from '@clack/prompts'; +import type { AgentFormat } from '@endorhq/capsule-shared/types/timeline'; import pc from 'picocolors'; -import { promptAndAnonymize } from '../flows/anonymize-prompt.js'; +import { resolveAnonymization } from '../flows/anonymize-prompt.js'; import { resolveSession } from '../flows/session.js'; import { saveToFile } from '../publish.js'; -export default async function exportCmd(fileArg?: string): Promise { - p.intro(pc.bgCyan(pc.black(' capsule export '))); +export interface ExportOptions { + output?: string; + anonymize?: string; + format?: string; +} + +export default async function exportCmd( + session: string | undefined, + options: ExportOptions +): Promise { + const interactive = Boolean(process.stdin.isTTY); - const { content, format } = await resolveSession(fileArg); - const anonymized = await promptAndAnonymize(content, format); + if (interactive) { + p.intro(pc.bgCyan(pc.black(' capsule export '))); + } + + const { content, format } = await resolveSession({ + session, + format: options.format as AgentFormat | undefined, + interactive, + }); + + const anonymized = await resolveAnonymization(content, format, { + anonymize: options.anonymize, + interactive, + }); const ext = format === 'gemini' ? '.json' : '.jsonl'; const defaultName = `${format}-session-anonymized${ext}`; - const outputPath = await p.text({ - message: 'Output file path:', - placeholder: defaultName, - defaultValue: defaultName, - }); - if (p.isCancel(outputPath)) { - p.cancel('Cancelled.'); - process.exit(0); + let outputPath: string; + if (options.output) { + outputPath = options.output; + } else if (interactive) { + const pathInput = await p.text({ + message: 'Output file path:', + placeholder: defaultName, + defaultValue: defaultName, + }); + if (p.isCancel(pathInput)) { + p.cancel('Cancelled.'); + process.exit(0); + } + outputPath = pathInput; + } else { + outputPath = defaultName; } const resolved = resolve(outputPath); await saveToFile(anonymized, resolved); - p.log.success(`Saved to ${pc.cyan(resolved)}`); - p.outro(pc.green('Done!')); + if (interactive) { + p.log.success(`Saved to ${pc.cyan(resolved)}`); + p.outro(pc.green('Done!')); + } else { + console.log(resolved); + } } diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index f43f356..0c39371 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2,17 +2,12 @@ import { createServer } from 'node:http'; import * as p from '@clack/prompts'; import pc from 'picocolors'; -function parsePortArg(): number | undefined { - const args = process.argv.slice(3); - const portIdx = args.indexOf('--port'); - if (portIdx !== -1 && args[portIdx + 1]) { - const port = Number.parseInt(args[portIdx + 1], 10); - if (!Number.isNaN(port) && port > 0 && port < 65536) return port; - } +export interface ServeOptions { + port?: number; } -export default async function serve(): Promise { - const port = parsePortArg() || 3123; +export default async function serve(options: ServeOptions): Promise { + const port = options.port || 3123; p.intro(pc.bgCyan(pc.black(' capsule serve '))); diff --git a/packages/cli/src/commands/share.ts b/packages/cli/src/commands/share.ts index 849cb97..84522bd 100644 --- a/packages/cli/src/commands/share.ts +++ b/packages/cli/src/commands/share.ts @@ -1,50 +1,95 @@ import * as p from '@clack/prompts'; +import type { AgentFormat } from '@endorhq/capsule-shared/types/timeline'; import pc from 'picocolors'; -import { promptAndAnonymize } from '../flows/anonymize-prompt.js'; +import { resolveAnonymization } from '../flows/anonymize-prompt.js'; import { resolveSession } from '../flows/session.js'; import { checkGhAuth, publishGist } from '../publish.js'; -export default async function share(fileArg?: string): Promise { - p.intro(pc.bgCyan(pc.black(' capsule share '))); +export interface ShareOptions { + format?: string; +} + +export default async function share( + session: string | undefined, + options: ShareOptions +): Promise { + const interactive = Boolean(process.stdin.isTTY); + + if (interactive) { + p.intro(pc.bgCyan(pc.black(' capsule share '))); + } const authCheck = await checkGhAuth(); if (!authCheck.ok) { - p.log.error(authCheck.error || 'Authentication failed'); - p.outro('Cannot publish without gh authentication.'); + if (interactive) { + p.log.error(authCheck.error || 'Authentication failed'); + p.outro('Cannot publish without gh authentication.'); + } else { + console.error(authCheck.error || 'Authentication failed'); + } process.exit(1); } - const { content, format } = await resolveSession(fileArg); - const anonymized = await promptAndAnonymize(content, format); + const { content, format } = await resolveSession({ + session, + format: options.format as AgentFormat | undefined, + interactive, + }); - const visibility = await p.select({ - message: 'Gist visibility:', - options: [ - { value: 'secret', label: 'Secret', hint: 'only accessible via link' }, - { value: 'public', label: 'Public', hint: 'visible in your profile' }, - ], + const anonymized = await resolveAnonymization(content, format, { + interactive, }); - if (p.isCancel(visibility)) { - p.cancel('Cancelled.'); - process.exit(0); - } - const spinner = p.spinner(); - spinner.start('Creating gist'); - try { - const result = await publishGist(anonymized, format, { - public: visibility === 'public', - description: `Agent session log (${format})`, + let visibility: string; + if (interactive) { + const visibilityChoice = await p.select({ + message: 'Gist visibility:', + options: [ + { value: 'secret', label: 'Secret', hint: 'only accessible via link' }, + { value: 'public', label: 'Public', hint: 'visible in your profile' }, + ], }); - spinner.stop('Gist created'); + if (p.isCancel(visibilityChoice)) { + p.cancel('Cancelled.'); + process.exit(0); + } + visibility = visibilityChoice; + } else { + visibility = 'secret'; + } - p.log.success(`Gist: ${pc.underline(pc.cyan(result.gistUrl))}`); - p.log.success(`View: ${pc.underline(pc.green(result.viewerUrl))}`); - } catch (err) { - spinner.stop('Failed to create gist'); - p.log.error(err instanceof Error ? err.message : String(err)); - process.exit(1); + if (interactive) { + const spinner = p.spinner(); + spinner.start('Creating gist'); + try { + const result = await publishGist(anonymized, format, { + public: visibility === 'public', + description: `Agent session log (${format})`, + }); + spinner.stop('Gist created'); + + p.log.success(`Gist: ${pc.underline(pc.cyan(result.gistUrl))}`); + p.log.success(`View: ${pc.underline(pc.green(result.viewerUrl))}`); + } catch (err) { + spinner.stop('Failed to create gist'); + p.log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + } else { + try { + const result = await publishGist(anonymized, format, { + public: visibility === 'public', + description: `Agent session log (${format})`, + }); + console.log(result.gistUrl); + console.log(result.viewerUrl); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } } - p.outro(pc.green('Done!')); + if (interactive) { + p.outro(pc.green('Done!')); + } } diff --git a/packages/cli/src/flows/anonymize-prompt.ts b/packages/cli/src/flows/anonymize-prompt.ts index ecc1ad8..1b535f3 100644 --- a/packages/cli/src/flows/anonymize-prompt.ts +++ b/packages/cli/src/flows/anonymize-prompt.ts @@ -9,19 +9,52 @@ import { const SELECT_ALL = '__select_all__' as const; +const VALID_KEYS = Object.keys(ANONYMIZE_OPTION_LABELS) as Array< + keyof AnonymizeOptions +>; + +function parseAnonymizeFlag(value: string): Array { + if (value === 'all') return [...VALID_KEYS]; + if (value === 'none') return []; + + const keys = value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + const invalid = keys.filter( + k => !VALID_KEYS.includes(k as keyof AnonymizeOptions) + ); + if (invalid.length > 0) { + throw new Error( + `Invalid anonymize options: ${invalid.join(', ')}. Valid options: ${VALID_KEYS.join(', ')}` + ); + } + return keys as Array; +} + +function applyAnonymization( + content: string, + format: AgentFormat, + selectedKeys: Array +): string { + if (selectedKeys.length === 0) return content; + + const options: AnonymizeOptions = { ...DEFAULT_OPTIONS }; + for (const key of selectedKeys) { + options[key] = true; + } + return anonymize(content, format, options); +} + export async function promptAndAnonymize( content: string, format: AgentFormat ): Promise { - const optionKeys = Object.keys(ANONYMIZE_OPTION_LABELS) as Array< - keyof AnonymizeOptions - >; - const anonChoices = await p.multiselect({ message: 'Select anonymization options:', options: [ { value: SELECT_ALL, label: 'Select all' }, - ...optionKeys.map(key => ({ + ...VALID_KEYS.map(key => ({ value: key, label: ANONYMIZE_OPTION_LABELS[key], })), @@ -35,18 +68,13 @@ export async function promptAndAnonymize( const selectAll = anonChoices.includes(SELECT_ALL as never); const selectedKeys = selectAll - ? optionKeys + ? VALID_KEYS : (anonChoices as Array); - const options: AnonymizeOptions = { ...DEFAULT_OPTIONS }; - for (const key of selectedKeys) { - options[key] = true; - } - if (selectedKeys.length > 0) { const spinner = p.spinner(); spinner.start('Anonymizing session'); - const anonymized = anonymize(content, format, options); + const anonymized = applyAnonymization(content, format, selectedKeys); spinner.stop('Session anonymized'); return anonymized; } @@ -54,3 +82,17 @@ export async function promptAndAnonymize( p.log.info('No anonymization applied'); return content; } + +export async function resolveAnonymization( + content: string, + format: AgentFormat, + options: { anonymize?: string; interactive: boolean } +): Promise { + if (options.interactive && options.anonymize === undefined) { + return promptAndAnonymize(content, format); + } + + const flagValue = options.anonymize ?? 'none'; + const selectedKeys = parseAnonymizeFlag(flagValue); + return applyAnonymization(content, format, selectedKeys); +} diff --git a/packages/cli/src/flows/session.ts b/packages/cli/src/flows/session.ts index 7260a32..954a206 100644 --- a/packages/cli/src/flows/session.ts +++ b/packages/cli/src/flows/session.ts @@ -12,6 +12,14 @@ export interface ResolvedSession { format: AgentFormat; } +export interface ResolveSessionOptions { + session?: string; + format?: AgentFormat; + interactive?: boolean; +} + +const KNOWN_AGENTS: AgentFormat[] = ['claude', 'codex', 'copilot', 'gemini']; + function formatDate(date: Date): string { const now = new Date(); const diff = now.getTime() - date.getTime(); @@ -33,109 +41,166 @@ function formatDate(date: Date): string { ); } -export async function resolveSession( - fileArg?: string +function parseSessionSpecifier( + session: string +): { agent: AgentFormat; sessionId: string } | null { + const colonIdx = session.indexOf(':'); + if (colonIdx === -1) return null; + + const prefix = session.slice(0, colonIdx) as AgentFormat; + const id = session.slice(colonIdx + 1); + + if (!KNOWN_AGENTS.includes(prefix) || !id) return null; + return { agent: prefix, sessionId: id }; +} + +async function resolveBySpecifier( + agent: AgentFormat, + sessionId: string ): Promise { - let fileContent: string | undefined; - let format: AgentFormat | undefined; - - if (fileArg) { - const resolved = resolve(fileArg); - try { - const s = await stat(resolved); - if (s.isFile()) { - fileContent = await readFile(resolved, 'utf-8'); - const ext = extname(resolved); - const fileFormat = ext === '.json' ? 'json' : 'jsonl'; - format = detectFormat(fileContent, fileFormat as 'json' | 'jsonl'); - if (format === 'unknown') { - p.log.warn(`Could not auto-detect format for ${pc.dim(resolved)}`); - const formatChoice = await p.select({ - message: 'Select the session format:', - options: [ - { value: 'claude', label: 'Claude Code' }, - { value: 'codex', label: 'Codex' }, - { value: 'copilot', label: 'Copilot' }, - { value: 'gemini', label: 'Gemini CLI' }, - ], - }); - if (p.isCancel(formatChoice)) { - p.cancel('Cancelled.'); - process.exit(0); - } - format = formatChoice as AgentFormat; - } - p.log.info(`File: ${pc.cyan(resolved)} ${pc.dim(`(${format})`)}`); - } - } catch { - p.log.error(`File not found: ${fileArg}`); - process.exit(1); - } + const sources = await discoverAllSessions(); + const allSessions = sources.flatMap(s => s.sessions); + const match = allSessions.find( + s => s.agent === agent && s.sessionId.startsWith(sessionId) + ); + + if (!match) { + throw new Error(`No ${agent} session found matching ID '${sessionId}'`); } - if (!fileContent) { - const spinner = p.spinner(); - spinner.start('Discovering agent sessions'); - const sources = await discoverAllSessions(); - spinner.stop('Discovery complete'); + const content = await readFile(match.filePath, 'utf-8'); + return { content, format: match.agent }; +} - if (sources.length === 0) { - p.log.error('No agent sessions found on this machine.'); - p.log.info(pc.dim('Checked: ~/.claude, ~/.codex, ~/.copilot, ~/.gemini')); - p.outro('Nothing to do.'); - process.exit(0); - } +async function resolveByFile( + filePath: string, + formatOverride: AgentFormat | undefined, + interactive: boolean +): Promise { + const resolved = resolve(filePath); + let s: Awaited>; + try { + s = await stat(resolved); + } catch { + throw new Error(`File not found: ${filePath}`); + } - let selectedSource: AgentSource; - if (sources.length === 1) { - selectedSource = sources[0]; - p.log.info( - `Found ${pc.cyan(String(selectedSource.sessionCount))} ${selectedSource.label} sessions` - ); - } else { - const sourceChoice = await p.select({ - message: 'Select an agent:', - options: sources.map(s => ({ - value: s, - label: `${s.label}`, - hint: `${s.sessionCount} sessions`, - })), + if (!s.isFile()) { + throw new Error(`Not a file: ${filePath}`); + } + + const content = await readFile(resolved, 'utf-8'); + const ext = extname(resolved); + const fileFormat = ext === '.json' ? 'json' : 'jsonl'; + let format = detectFormat(content, fileFormat as 'json' | 'jsonl'); + + if (format === 'unknown') { + if (formatOverride) { + format = formatOverride; + } else if (interactive) { + p.log.warn(`Could not auto-detect format for ${pc.dim(resolved)}`); + const formatChoice = await p.select({ + message: 'Select the session format:', + options: [ + { value: 'claude', label: 'Claude Code' }, + { value: 'codex', label: 'Codex' }, + { value: 'copilot', label: 'Copilot' }, + { value: 'gemini', label: 'Gemini CLI' }, + ], }); - if (p.isCancel(sourceChoice)) { + if (p.isCancel(formatChoice)) { p.cancel('Cancelled.'); process.exit(0); } - selectedSource = sourceChoice; + format = formatChoice as AgentFormat; + } else { + throw new Error( + 'Format could not be auto-detected. Use --format to specify.' + ); } + } - const sessions = selectedSource.sessions; + if (interactive) { + p.log.info(`File: ${pc.cyan(resolved)} ${pc.dim(`(${format})`)}`); + } + + return { content, format }; +} - const sessionChoice = await p.select< - DiscoveredSession[], - DiscoveredSession - >({ - message: 'Select a session:', - options: sessions.slice(0, 50).map(s => ({ +async function resolveInteractively(): Promise { + const spinner = p.spinner(); + spinner.start('Discovering agent sessions'); + const sources = await discoverAllSessions(); + spinner.stop('Discovery complete'); + + if (sources.length === 0) { + p.log.error('No agent sessions found on this machine.'); + p.log.info(pc.dim('Checked: ~/.claude, ~/.codex, ~/.copilot, ~/.gemini')); + p.outro('Nothing to do.'); + process.exit(0); + } + + let selectedSource: AgentSource; + if (sources.length === 1) { + selectedSource = sources[0]; + p.log.info( + `Found ${pc.cyan(String(selectedSource.sessionCount))} ${selectedSource.label} sessions` + ); + } else { + const sourceChoice = await p.select({ + message: 'Select an agent:', + options: sources.map(s => ({ value: s, - label: s.title, - hint: `${formatDate(s.date)}${s.cwd ? ` \u2022 ${pc.dim(s.cwd)}` : ''}`, + label: `${s.label}`, + hint: `${s.sessionCount} sessions`, })), }); - if (p.isCancel(sessionChoice)) { + if (p.isCancel(sourceChoice)) { p.cancel('Cancelled.'); process.exit(0); } + selectedSource = sourceChoice; + } + + const sessions = selectedSource.sessions; - const selected = sessionChoice; - format = selected.agent; - fileContent = await readFile(selected.filePath, 'utf-8'); - p.log.info(`Session: ${pc.cyan(selected.title)} ${pc.dim(`(${format})`)}`); + const sessionChoice = await p.select({ + message: 'Select a session:', + options: sessions.slice(0, 50).map(s => ({ + value: s, + label: s.title, + hint: `${formatDate(s.date)}${s.cwd ? ` \u2022 ${pc.dim(s.cwd)}` : ''}`, + })), + }); + if (p.isCancel(sessionChoice)) { + p.cancel('Cancelled.'); + process.exit(0); + } + + const selected = sessionChoice; + const format = selected.agent; + const content = await readFile(selected.filePath, 'utf-8'); + p.log.info(`Session: ${pc.cyan(selected.title)} ${pc.dim(`(${format})`)}`); + + return { content, format }; +} + +export async function resolveSession( + options: ResolveSessionOptions = {} +): Promise { + const { session, format, interactive = true } = options; + + if (session) { + const specifier = parseSessionSpecifier(session); + if (specifier) { + return resolveBySpecifier(specifier.agent, specifier.sessionId); + } + return resolveByFile(session, format, interactive); } - if (!fileContent || !format) { - p.cancel('No session loaded.'); - process.exit(1); + if (!interactive) { + throw new Error('Session argument required in non-interactive mode'); } - return { content: fileContent, format }; + return resolveInteractively(); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9c9c16d..0f79a46 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,52 +1,65 @@ +import { Command } from 'commander'; import pc from 'picocolors'; -const USAGE = ` -${pc.bold('capsule')} — Share and view AI agent session logs +const program = new Command(); -${pc.bold('Usage:')} - capsule share [file] Publish a session to GitHub Gist - capsule export [file] Save a session to a local file - capsule serve [--port N] Start a local web viewer +program + .name('capsule') + .version('0.0.1') + .description('Share and view AI agent session logs'); -${pc.bold('Options:')} - --help, -h Show this help message -`; +program + .command('share') + .description('Publish a session to GitHub Gist') + .argument('[session]', 'file path or agent:sessionId specifier') + .option( + '--format ', + 'session format (claude, codex, copilot, gemini)' + ) + .action(async (session: string | undefined, options: { format?: string }) => { + const { default: share } = await import('./commands/share.js'); + await share(session, options); + }); -async function main() { - const command = process.argv[2]; - - if (!command || command === '--help' || command === '-h') { - console.log(USAGE); - process.exit(0); - } - - const fileArg = process.argv[3]; - - switch (command) { - case 'share': { - const { default: share } = await import('./commands/share.js'); - await share(fileArg); - break; - } - case 'export': { +program + .command('export') + .description('Save a session to a local file') + .argument('[session]', 'file path or agent:sessionId specifier') + .option('--output ', 'output file path') + .option( + '--anonymize ', + 'anonymization: all, none, or comma-separated options' + ) + .option( + '--format ', + 'session format (claude, codex, copilot, gemini)' + ) + .action( + async ( + session: string | undefined, + options: { output?: string; anonymize?: string; format?: string } + ) => { const { default: exportCmd } = await import('./commands/export.js'); - await exportCmd(fileArg); - break; - } - case 'serve': { - const { default: serve } = await import('./commands/serve.js'); - await serve(); - break; + await exportCmd(session, options); } - default: { - console.error(`${pc.red('Unknown command:')} ${command}`); - console.log(USAGE); - process.exit(1); + ); + +program + .command('serve') + .description('Start a local web viewer') + .option('--port ', 'port number (default: 3123)', v => { + const n = Number.parseInt(v, 10); + if (Number.isNaN(n) || n <= 0 || n >= 65536) { + throw new Error(`Invalid port number: ${v}`); } - } -} + return n; + }) + .action(async (options: { port?: number }) => { + const { default: serve } = await import('./commands/serve.js'); + await serve(options); + }); -main().catch(err => { +program.parseAsync().catch(err => { console.error(pc.red(err instanceof Error ? err.message : String(err))); process.exit(1); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac8125..fad61f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@clack/prompts': specifier: ^0.10.0 version: 0.10.1 + commander: + specifier: ^12.0.0 + version: 12.1.0 picocolors: specifier: ^1.1.0 version: 1.1.1 @@ -1175,6 +1178,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2641,6 +2648,8 @@ snapshots: clsx@2.1.1: {} + commander@12.1.0: {} + commander@4.1.1: {} commondir@1.0.1: {}