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: 49 additions & 15 deletions packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
}
}
13 changes: 4 additions & 9 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const port = parsePortArg() || 3123;
export default async function serve(options: ServeOptions): Promise<void> {
const port = options.port || 3123;

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

Expand Down
107 changes: 76 additions & 31 deletions packages/cli/src/commands/share.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
p.intro(pc.bgCyan(pc.black(' capsule share ')));
export interface ShareOptions {
format?: string;
}

export default async function share(
session: string | undefined,
options: ShareOptions
): Promise<void> {
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!'));
}
}
66 changes: 54 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,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<keyof AnonymizeOptions> {
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<keyof AnonymizeOptions>;
}

function applyAnonymization(
content: string,
format: AgentFormat,
selectedKeys: Array<keyof AnonymizeOptions>
): 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<string> {
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],
})),
Expand All @@ -35,22 +68,31 @@ 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 = applyAnonymization(content, format, selectedKeys);
spinner.stop('Session anonymized');
return anonymized;
}

p.log.info('No anonymization applied');
return content;
}

export async function resolveAnonymization(
content: string,
format: AgentFormat,
options: { anonymize?: string; interactive: boolean }
): Promise<string> {
if (options.interactive && options.anonymize === undefined) {
return promptAndAnonymize(content, format);
}

const flagValue = options.anonymize ?? 'none';
const selectedKeys = parseAnonymizeFlag(flagValue);
return applyAnonymization(content, format, selectedKeys);
}
Loading