Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"commander": "^12.0.0",
"picocolors": "^1.1.0"
},
"devDependencies": {
Expand Down
64 changes: 48 additions & 16 deletions packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,64 @@
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 { resolveSession } from '../flows/session.js';
import { saveToFile } from '../publish.js';

export default async function exportCmd(fileArg?: string): Promise<void> {
p.intro(pc.bgCyan(pc.black(' capsule export ')));
export interface ExportOptions {
format?: string;
anonymize?: string | false;
output?: string;
}

export default async function exportCmd(
sessionArg?: string,
options?: ExportOptions
): Promise<void> {
if (process.stdout.isTTY) {
p.intro(pc.bgCyan(pc.black(' capsule export ')));
}

const { content, format } = await resolveSession(sessionArg, {
format: options?.format as AgentFormat | undefined,
});

const { content, format } = await resolveSession(fileArg);
const anonymized = await promptAndAnonymize(content, format);
const noAnonymize = options?.anonymize === false;
const anonymizeKeys =
typeof options?.anonymize === 'string' ? options.anonymize : undefined;
const anonymized = await promptAndAnonymize(content, format, {
anonymize: anonymizeKeys,
noAnonymize,
});

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 = resolve(options.output);
} else if (process.stdout.isTTY) {
const choice = await p.text({
message: 'Output file path:',
placeholder: defaultName,
defaultValue: defaultName,
});
if (p.isCancel(choice)) {
p.cancel('Cancelled.');
process.exit(0);
}
outputPath = resolve(choice);
} else {
outputPath = resolve(defaultName);
}

const resolved = resolve(outputPath);
await saveToFile(anonymized, resolved);
p.log.success(`Saved to ${pc.cyan(resolved)}`);
await saveToFile(anonymized, outputPath);

p.outro(pc.green('Done!'));
if (process.stdout.isTTY) {
p.log.success(`Saved to ${pc.cyan(outputPath)}`);
p.outro(pc.green('Done!'));
} else {
console.log(`path: ${outputPath}`);
}
}
17 changes: 8 additions & 9 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ 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?: string;
}

export default async function serve(): Promise<void> {
const port = parsePortArg() || 3123;
export default async function serve(options?: ServeOptions): Promise<void> {
const port = options?.port ? Number.parseInt(options.port, 10) : 3123;
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
console.error('Invalid port number. Must be between 1 and 65535.');
process.exit(1);
}

p.intro(pc.bgCyan(pc.black(' capsule serve ')));

Expand Down
126 changes: 95 additions & 31 deletions packages/cli/src/commands/share.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,114 @@
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 { resolveSession } from '../flows/session.js';
import { checkGhAuth, publishGist } from '../publish.js';

export default async function share(fileArg?: string): Promise<void> {
p.intro(pc.bgCyan(pc.black(' capsule share ')));
export interface ShareOptions {
format?: string;
public?: boolean;
secret?: boolean;
anonymize?: string | false;
}

export default async function share(
sessionArg?: string,
options?: ShareOptions
): Promise<void> {
if (process.stdout.isTTY) {
p.intro(pc.bgCyan(pc.black(' capsule share ')));
}

if (options?.public && options?.secret) {
if (process.stdout.isTTY) {
p.log.error('Cannot use both --public and --secret');
p.outro('Pick one visibility option.');
} else {
console.error('Cannot use both --public and --secret');
}
process.exit(1);
}

const authCheck = await checkGhAuth();
if (!authCheck.ok) {
p.log.error(authCheck.error || 'Authentication failed');
p.outro('Cannot publish without gh authentication.');
if (process.stdout.isTTY) {
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(sessionArg, {
format: options?.format as AgentFormat | undefined,
});

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 noAnonymize = options?.anonymize === false;
const anonymizeKeys =
typeof options?.anonymize === 'string' ? options.anonymize : undefined;
const anonymized = await promptAndAnonymize(content, format, {
anonymize: anonymizeKeys,
noAnonymize,
});
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: 'public' | 'secret';
if (options?.public) {
visibility = 'public';
} else if (options?.secret) {
visibility = 'secret';
} else if (process.stdout.isTTY) {
const choice = 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');

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 (p.isCancel(choice)) {
p.cancel('Cancelled.');
process.exit(0);
}
visibility = choice as 'public' | 'secret';
} else {
visibility = 'secret';
}

p.outro(pc.green('Done!'));
if (process.stdout.isTTY) {
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);
}

p.outro(pc.green('Done!'));
} else {
try {
const result = await publishGist(anonymized, format, {
public: visibility === 'public',
description: `Agent session log (${format})`,
});

console.log(`gist: ${result.gistUrl}`);
console.log(`viewer: ${result.viewerUrl}`);
console.log(`id: ${result.gistId}`);
console.log(`format: ${format}`);
console.log(`visibility: ${visibility}`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
}
85 changes: 73 additions & 12 deletions packages/cli/src/flows/anonymize-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,85 @@ import {

const SELECT_ALL = '__select_all__' as const;

const VALID_KEYS = Object.keys(ANONYMIZE_OPTION_LABELS) as Array<
keyof AnonymizeOptions
>;

export interface AnonymizeFlags {
anonymize?: string;
noAnonymize?: boolean;
}

function parseAnonymizeKeys(input: string): Array<keyof AnonymizeOptions> {
if (input === 'all') return [...VALID_KEYS];
if (input === 'none') return [];

const keys = input.split(',').map(k => k.trim());
const invalid = keys.filter(
k => !VALID_KEYS.includes(k as keyof AnonymizeOptions)
);
if (invalid.length > 0) {
console.error(
`Invalid anonymize option(s): ${invalid.join(', ')}\nValid options: ${VALID_KEYS.join(', ')}, all, none`
);
process.exit(1);
}
return keys as Array<keyof AnonymizeOptions>;
}

function applyKeys(
content: string,
format: AgentFormat,
selectedKeys: Array<keyof AnonymizeOptions>
): string {
const options: AnonymizeOptions = { ...DEFAULT_OPTIONS };
for (const key of selectedKeys) {
options[key] = true;
}

if (selectedKeys.length > 0) {
return anonymize(content, format, options);
}
return content;
}

export async function promptAndAnonymize(
content: string,
format: AgentFormat
format: AgentFormat,
flags?: AnonymizeFlags
): Promise<string> {
const optionKeys = Object.keys(ANONYMIZE_OPTION_LABELS) as Array<
keyof AnonymizeOptions
>;
// --no-anonymize: skip entirely
if (flags?.noAnonymize) {
if (process.stdout.isTTY) {
p.log.info('No anonymization applied');
}
return content;
}

// --anonymize <keys>: apply specified options without prompting
if (flags?.anonymize) {
const selectedKeys = parseAnonymizeKeys(flags.anonymize);
if (process.stdout.isTTY) {
const spinner = p.spinner();
spinner.start('Anonymizing session');
const result = applyKeys(content, format, selectedKeys);
spinner.stop('Session anonymized');
return result;
}
return applyKeys(content, format, selectedKeys);
}

// No flags: non-TTY defaults to no anonymization
if (!process.stdout.isTTY) {
return content;
}

// TTY: interactive multiselect
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],
})),
Expand All @@ -35,18 +101,13 @@ export async function promptAndAnonymize(

const selectAll = anonChoices.includes(SELECT_ALL as never);
const selectedKeys = selectAll
? optionKeys
? VALID_KEYS
: (anonChoices as Array<keyof AnonymizeOptions>);

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 = applyKeys(content, format, selectedKeys);
spinner.stop('Session anonymized');
return anonymized;
}
Expand Down
Loading