@jefferylau/euphony-skills 0.1.0

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.
@@ -0,0 +1,646 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const isWindows = process.platform === 'win32';
9
+ const home = os.homedir();
10
+ const codebuddyHome = process.env.CODEBUDDY_HOME || path.join(home, '.codebuddy');
11
+ const projectsDir = process.env.CODEBUDDY_PROJECTS_DIR || path.join(codebuddyHome, 'projects');
12
+ const includeSubagents = process.env.CODEBUDDY_INCLUDE_SUBAGENTS === '1';
13
+ const euphonyDir = process.env.EUPHONY_DIR || path.join(codebuddyHome, 'cache', 'euphony');
14
+ const euphonyRepo = process.env.EUPHONY_REPO || 'https://github.com/openai/euphony.git';
15
+ const host = process.env.EUPHONY_HOST || '127.0.0.1';
16
+ const port = Number(process.env.EUPHONY_PORT || '3000');
17
+ const maxLines = process.env.EUPHONY_FRONTEND_ONLY_MAX_LINES || '100000';
18
+ const baseUrl = `http://${host}:${port}/`;
19
+ const runDir = process.env.EUPHONY_RUN_DIR || path.join(euphonyDir, '.codebuddy-euphony');
20
+ const pidFile = path.join(runDir, 'vite.pid');
21
+ const logFile = path.join(runDir, 'vite.log');
22
+ const stagedDir = path.join(euphonyDir, 'public', 'local-codebuddy');
23
+ const stagedJsonl = path.join(stagedDir, 'latest.jsonl');
24
+ const stagedSource = path.join(stagedDir, 'latest-source.txt');
25
+
26
+ function usage() {
27
+ console.log(`Usage: ${path.basename(process.argv[1])} <command> [session-jsonl] [output-jsonl]
28
+
29
+ Commands:
30
+ list List recent CodeBuddy session JSONL files.
31
+ latest Print the newest CodeBuddy session JSONL path.
32
+ convert [in] [out]
33
+ Convert a CodeBuddy session to Euphony-compatible JSONL.
34
+ stage [file] Convert into Euphony public/local-codebuddy/latest.jsonl and print a load URL.
35
+ open [file] Stage a session, ensure Euphony is running, and open it in the browser.
36
+ up Start Euphony in the background if it is not already running.
37
+ start Start Euphony Vite dev server in the foreground.
38
+ status Check whether Euphony responds.
39
+ stop Stop the Euphony Vite server for this checkout.
40
+ restart Stop then start Euphony in the background.
41
+
42
+ Environment:
43
+ CODEBUDDY_HOME Default: ~/.codebuddy
44
+ CODEBUDDY_PROJECTS_DIR Default: $CODEBUDDY_HOME/projects
45
+ CODEBUDDY_INCLUDE_SUBAGENTS=1 to include subagent JSONL files
46
+ EUPHONY_DIR Default: $CODEBUDDY_HOME/cache/euphony
47
+ EUPHONY_HOST Default: 127.0.0.1
48
+ EUPHONY_PORT Default: 3000
49
+ EUPHONY_RUN_DIR Default: $EUPHONY_DIR/.codebuddy-euphony
50
+ EUPHONY_FRONTEND_ONLY_MAX_LINES
51
+ Default: 100000`);
52
+ }
53
+
54
+ function fail(message) {
55
+ console.error(message);
56
+ process.exit(1);
57
+ }
58
+
59
+ function commandExists(command) {
60
+ try {
61
+ if (isWindows) {
62
+ execFileSync('where', [command], { stdio: 'ignore', shell: true });
63
+ } else {
64
+ execFileSync('which', [command], { stdio: 'ignore' });
65
+ }
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function requireCommand(command) {
73
+ if (!commandExists(command)) fail(`${command} is required for this command.`);
74
+ }
75
+
76
+ function run(command, args, options = {}) {
77
+ return execFileSync(command, args, {
78
+ ...options,
79
+ shell: isWindows && command === 'corepack',
80
+ stdio: options.stdio || 'inherit'
81
+ });
82
+ }
83
+
84
+ function packageRunner() {
85
+ if (commandExists('pnpm')) {
86
+ return { command: 'pnpm', argsPrefix: [], env: process.env, shell: isWindows };
87
+ }
88
+ requireCommand('corepack');
89
+ return {
90
+ command: 'corepack',
91
+ argsPrefix: ['pnpm'],
92
+ env: {
93
+ ...process.env,
94
+ COREPACK_INTEGRITY_KEYS: process.env.COREPACK_INTEGRITY_KEYS || '0'
95
+ },
96
+ shell: isWindows
97
+ };
98
+ }
99
+
100
+ function runPnpm(args, options = {}) {
101
+ const runner = packageRunner();
102
+ if (runner.command === 'corepack' && !process.env.COREPACK_INTEGRITY_KEYS) {
103
+ console.log('Using Corepack with COREPACK_INTEGRITY_KEYS=0 to avoid known pnpm signature bootstrap failures.');
104
+ }
105
+ return execFileSync(runner.command, [...runner.argsPrefix, ...args], {
106
+ ...options,
107
+ env: runner.env,
108
+ shell: runner.shell,
109
+ stdio: options.stdio || 'inherit'
110
+ });
111
+ }
112
+
113
+ function spawnPnpm(args, options = {}) {
114
+ const runner = packageRunner();
115
+ return spawn(runner.command, [...runner.argsPrefix, ...args], {
116
+ ...options,
117
+ env: {
118
+ ...runner.env,
119
+ ...(options.env || {})
120
+ },
121
+ shell: runner.shell
122
+ });
123
+ }
124
+
125
+ function requireProjectsDir() {
126
+ if (!fs.existsSync(projectsDir) || !fs.statSync(projectsDir).isDirectory()) {
127
+ fail(`CodeBuddy projects directory not found: ${projectsDir}`);
128
+ }
129
+ }
130
+
131
+ function patchEuphonyFrontendLimit() {
132
+ const apiManager = path.join(euphonyDir, 'src', 'utils', 'api-manager.ts');
133
+ if (!fs.existsSync(apiManager)) return;
134
+ const oldText = fs.readFileSync(apiManager, 'utf8');
135
+ if (oldText.includes('VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES')) return;
136
+ const needle =
137
+ '// The maximum number of lines in a JSONL file to read in frontend-only mode\nconst FRONTEND_ONLY_MODE_MAX_LINES = 100;';
138
+ if (!oldText.includes(needle)) return;
139
+ const replacement =
140
+ "// The maximum number of lines in a JSONL file to read in frontend-only mode.\n" +
141
+ '// CodeBuddy converted sessions can exceed 100 lines, so keep the default\n' +
142
+ '// high while allowing local deployments to lower it.\n' +
143
+ 'const FRONTEND_ONLY_MODE_MAX_LINES = Number.parseInt(\n' +
144
+ " (import.meta.env.VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES as string) || '100000',\n" +
145
+ ' 10\n' +
146
+ ');';
147
+ fs.writeFileSync(apiManager, oldText.replace(needle, replacement));
148
+ }
149
+
150
+ function ensureEuphonyDir() {
151
+ const packageJson = path.join(euphonyDir, 'package.json');
152
+ if (fs.existsSync(packageJson)) {
153
+ patchEuphonyFrontendLimit();
154
+ if (!fs.existsSync(path.join(euphonyDir, 'node_modules'))) {
155
+ runPnpm(['install'], { cwd: euphonyDir });
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (fs.existsSync(euphonyDir)) {
161
+ fail(`EUPHONY_DIR exists but is not an Euphony checkout: ${euphonyDir}
162
+ Remove it or set EUPHONY_DIR to another path.`);
163
+ }
164
+
165
+ requireCommand('git');
166
+ fs.mkdirSync(path.dirname(euphonyDir), { recursive: true });
167
+ console.log(`Cloning Euphony into ${euphonyDir}...`);
168
+ run('git', ['clone', euphonyRepo, euphonyDir]);
169
+ patchEuphonyFrontendLimit();
170
+ console.log(`Installing Euphony dependencies in ${euphonyDir}...`);
171
+ runPnpm(['install'], { cwd: euphonyDir });
172
+ }
173
+
174
+ function walkJsonlFiles(dir, out = []) {
175
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
176
+ if (entry.name === '.DS_Store') continue;
177
+ const fullPath = path.join(dir, entry.name);
178
+ if (entry.isDirectory()) {
179
+ walkJsonlFiles(fullPath, out);
180
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
181
+ out.push(fullPath);
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ function recentSessions() {
188
+ requireProjectsDir();
189
+ return walkJsonlFiles(projectsDir)
190
+ .filter(file => includeSubagents || !file.split(path.sep).includes('subagents'))
191
+ .map(file => ({ file, mtimeMs: fs.statSync(file).mtimeMs }))
192
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
193
+ }
194
+
195
+ function latestSession() {
196
+ const [latest] = recentSessions();
197
+ if (!latest) fail(`No CodeBuddy session JSONL files found under: ${projectsDir}`);
198
+ return latest.file;
199
+ }
200
+
201
+ function toIsoTimestamp(value) {
202
+ if (typeof value === 'number' && Number.isFinite(value)) {
203
+ const ms = value > 10_000_000_000 ? value : value * 1000;
204
+ return new Date(ms).toISOString();
205
+ }
206
+ if (typeof value === 'string' && value.trim()) {
207
+ const numeric = Number(value);
208
+ if (Number.isFinite(numeric)) return toIsoTimestamp(numeric);
209
+ const parsed = Date.parse(value);
210
+ if (!Number.isNaN(parsed)) return new Date(parsed).toISOString();
211
+ }
212
+ return new Date().toISOString();
213
+ }
214
+
215
+ function safeJsonParse(line, lineNumber, source) {
216
+ try {
217
+ return JSON.parse(line);
218
+ } catch (error) {
219
+ return {
220
+ type: 'codebuddy-parse-error',
221
+ timestamp: new Date().toISOString(),
222
+ message: `Could not parse ${source}:${lineNumber}: ${error.message}`,
223
+ rawLine: line
224
+ };
225
+ }
226
+ }
227
+
228
+ function readJsonl(file) {
229
+ const text = fs.readFileSync(file, 'utf8');
230
+ return text
231
+ .split(/\r?\n/)
232
+ .map(line => line.trim())
233
+ .filter(Boolean)
234
+ .map((line, index) => safeJsonParse(line, index + 1, file));
235
+ }
236
+
237
+ function textFromContent(value) {
238
+ if (typeof value === 'string') return value;
239
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
240
+ if (Array.isArray(value)) return value.map(textFromContent).filter(Boolean).join('\n');
241
+ if (value && typeof value === 'object') {
242
+ for (const key of ['text', 'message', 'content', 'value']) {
243
+ if (key in value) {
244
+ const text = textFromContent(value[key]);
245
+ if (text) return text;
246
+ }
247
+ }
248
+ if (typeof value.type === 'string') return `[${value.type}]`;
249
+ }
250
+ return '';
251
+ }
252
+
253
+ function extractReasoning(event) {
254
+ const parts = [];
255
+ if (Array.isArray(event.rawContent)) {
256
+ for (const part of event.rawContent) {
257
+ const text = textFromContent(part);
258
+ if (text) parts.push(text);
259
+ }
260
+ }
261
+ if (Array.isArray(event.content)) {
262
+ for (const part of event.content) {
263
+ const text = textFromContent(part);
264
+ if (text) parts.push(text);
265
+ }
266
+ }
267
+ if (typeof event.providerData?.reasoning === 'string') parts.push(event.providerData.reasoning);
268
+ return [...new Set(parts)].join('\n\n').trim();
269
+ }
270
+
271
+ function stringifyToolOutput(output) {
272
+ if (typeof output === 'string') return output;
273
+ if (output && typeof output === 'object') {
274
+ const text = textFromContent(output);
275
+ if (text && text !== '[text]') return text;
276
+ }
277
+ return JSON.stringify(output ?? null, null, 2);
278
+ }
279
+
280
+ function codexEvent(event, payload, type = 'response_item') {
281
+ return { timestamp: toIsoTimestamp(event.timestamp), type, payload };
282
+ }
283
+
284
+ function convertEvent(event, index, sessionId) {
285
+ const id = event.id || `${sessionId}-codebuddy-${index}`;
286
+ if (event.type === 'message') {
287
+ const role = typeof event.role === 'string' ? event.role : 'assistant';
288
+ const text = textFromContent(event.content) || '[empty message]';
289
+ return codexEvent(event, {
290
+ type: 'message',
291
+ id,
292
+ role,
293
+ content: [{ type: role === 'assistant' ? 'output_text' : 'input_text', text }]
294
+ });
295
+ }
296
+ if (event.type === 'reasoning') {
297
+ const text = extractReasoning(event);
298
+ if (!text) return null;
299
+ return codexEvent(event, { type: 'reasoning', id, summary: [{ type: 'summary_text', text }] });
300
+ }
301
+ if (event.type === 'function_call') {
302
+ const args =
303
+ typeof event.arguments === 'string'
304
+ ? event.arguments
305
+ : JSON.stringify(event.arguments ?? {}, null, 2);
306
+ return codexEvent(event, {
307
+ type: 'function_call',
308
+ id,
309
+ name: event.name || event.providerData?.name || 'tool',
310
+ call_id: event.callId || event.call_id || id,
311
+ arguments: args,
312
+ status: event.status || 'completed'
313
+ });
314
+ }
315
+ if (event.type === 'function_call_result') {
316
+ return codexEvent(event, {
317
+ type: 'function_call_output',
318
+ id,
319
+ name: event.name || event.providerData?.name || 'tool',
320
+ call_id: event.callId || event.call_id || id,
321
+ output: stringifyToolOutput(event.output ?? event.providerData?.toolResult ?? event)
322
+ });
323
+ }
324
+ if (event.type === 'topic' && typeof event.topic === 'string') {
325
+ return codexEvent(event, {
326
+ type: 'message',
327
+ id,
328
+ role: 'system',
329
+ content: [{ type: 'input_text', text: `Topic: ${event.topic}` }]
330
+ });
331
+ }
332
+ if (event.type === 'codebuddy-parse-error') {
333
+ return codexEvent(event, {
334
+ type: 'message',
335
+ id,
336
+ role: 'system',
337
+ content: [{ type: 'input_text', text: event.message }]
338
+ });
339
+ }
340
+ return null;
341
+ }
342
+
343
+ function convertCodeBuddyToCodex(events, sourcePath) {
344
+ const first = events[0] || {};
345
+ const sessionId =
346
+ first.sessionId ||
347
+ events.find(event => typeof event.sessionId === 'string')?.sessionId ||
348
+ path.basename(sourcePath, '.jsonl');
349
+ const cwd =
350
+ first.cwd || events.find(event => typeof event.cwd === 'string')?.cwd || path.dirname(sourcePath);
351
+ const model = events.find(event => typeof event.providerData?.model === 'string')?.providerData.model || null;
352
+ const startedAt = toIsoTimestamp(first.timestamp);
353
+ const converted = [
354
+ {
355
+ timestamp: startedAt,
356
+ type: 'session_meta',
357
+ payload: {
358
+ id: sessionId,
359
+ timestamp: startedAt,
360
+ cwd,
361
+ originator: 'codebuddy',
362
+ source_path: sourcePath,
363
+ cli_version: 'codebuddy',
364
+ codebuddy_event_count: events.length
365
+ }
366
+ }
367
+ ];
368
+
369
+ if (model) converted.push({ timestamp: startedAt, type: 'turn_context', payload: { cwd, model, source: 'codebuddy' } });
370
+
371
+ for (let index = 0; index < events.length; index += 1) {
372
+ const mapped = convertEvent(events[index], index, sessionId);
373
+ if (mapped) converted.push(mapped);
374
+ }
375
+ return converted;
376
+ }
377
+
378
+ function writeJsonl(events, outFile) {
379
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
380
+ fs.writeFileSync(outFile, `${events.map(event => JSON.stringify(event)).join('\n')}\n`);
381
+ }
382
+
383
+ function convertCommand(input = latestSession(), output) {
384
+ const source = path.resolve(input);
385
+ if (!fs.existsSync(source)) fail(`Session file not found: ${source}`);
386
+ const outFile = output ? path.resolve(output) : source.replace(/\.jsonl$/, '.euphony.jsonl');
387
+ const converted = convertCodeBuddyToCodex(readJsonl(source), source);
388
+ writeJsonl(converted, outFile);
389
+ console.log(`Converted: ${source}`);
390
+ console.log(`Output: ${outFile}`);
391
+ return outFile;
392
+ }
393
+
394
+ function stageCommand(input = latestSession()) {
395
+ ensureEuphonyDir();
396
+ const source = path.resolve(input);
397
+ if (!fs.existsSync(source)) fail(`Session file not found: ${source}`);
398
+ const converted = convertCodeBuddyToCodex(readJsonl(source), source);
399
+ writeJsonl(converted, stagedJsonl);
400
+ fs.writeFileSync(stagedSource, `${source}\n`);
401
+ console.log(`Staged: ${source}`);
402
+ console.log(`Converted: ${stagedJsonl}`);
403
+ console.log(`Open: ${baseUrl}?path=${baseUrl}local-codebuddy/latest.jsonl&no-cache=true`);
404
+ }
405
+
406
+ function requestHead(url, timeoutMs = 2000) {
407
+ return new Promise(resolve => {
408
+ const request = http.request(url, { method: 'HEAD', timeout: timeoutMs }, response => {
409
+ response.resume();
410
+ resolve(response.statusCode >= 200 && response.statusCode < 500);
411
+ });
412
+ request.on('timeout', () => {
413
+ request.destroy();
414
+ resolve(false);
415
+ });
416
+ request.on('error', () => resolve(false));
417
+ request.end();
418
+ });
419
+ }
420
+
421
+ function readTrackedPid() {
422
+ if (!fs.existsSync(pidFile)) return null;
423
+ const text = fs.readFileSync(pidFile, 'utf8').trim();
424
+ if (!/^\d+$/.test(text)) return null;
425
+ return Number(text);
426
+ }
427
+
428
+ function pidExists(pid) {
429
+ if (!pid) return false;
430
+ try {
431
+ process.kill(pid, 0);
432
+ return true;
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
437
+
438
+ function trackedPid() {
439
+ const pid = readTrackedPid();
440
+ return pidExists(pid) ? pid : null;
441
+ }
442
+
443
+ function legacyCheckoutPids() {
444
+ if (isWindows || !commandExists('lsof')) return [];
445
+ try {
446
+ const output = execFileSync('lsof', ['-nP', `-tiTCP:${port}`, '-sTCP:LISTEN'], {
447
+ encoding: 'utf8',
448
+ stdio: ['ignore', 'pipe', 'ignore']
449
+ });
450
+ return output
451
+ .split(/\r?\n/)
452
+ .filter(Boolean)
453
+ .filter(pid => {
454
+ try {
455
+ const cwdOutput = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
456
+ encoding: 'utf8',
457
+ stdio: ['ignore', 'pipe', 'ignore']
458
+ });
459
+ const cwd = cwdOutput
460
+ .split(/\r?\n/)
461
+ .find(line => line.startsWith('n'))
462
+ ?.slice(1);
463
+ return cwd === euphonyDir;
464
+ } catch {
465
+ return false;
466
+ }
467
+ })
468
+ .map(Number);
469
+ } catch {
470
+ return [];
471
+ }
472
+ }
473
+
474
+ function trackedOrAdoptedPid() {
475
+ const pid = trackedPid();
476
+ if (pid) return pid;
477
+ const [legacyPid] = legacyCheckoutPids();
478
+ if (!legacyPid) return null;
479
+ fs.mkdirSync(runDir, { recursive: true });
480
+ fs.writeFileSync(pidFile, `${legacyPid}\n`);
481
+ return legacyPid;
482
+ }
483
+
484
+ function startViteBackground() {
485
+ fs.mkdirSync(runDir, { recursive: true });
486
+ const logFd = fs.openSync(logFile, 'a');
487
+ const child = spawnPnpm(['exec', 'vite', '--host', host, '--port', String(port)], {
488
+ cwd: euphonyDir,
489
+ detached: true,
490
+ env: {
491
+ VITE_EUPHONY_FRONTEND_ONLY: 'true',
492
+ VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES: maxLines
493
+ },
494
+ stdio: ['ignore', logFd, logFd]
495
+ });
496
+ child.unref();
497
+ fs.writeFileSync(pidFile, `${child.pid}\n`);
498
+ }
499
+
500
+ async function up() {
501
+ const existingPid = trackedOrAdoptedPid();
502
+ if (await requestHead(baseUrl)) {
503
+ if (existingPid) {
504
+ ensureEuphonyDir();
505
+ console.log(`Euphony responds at ${baseUrl}`);
506
+ console.log(`PID: ${existingPid}`);
507
+ return;
508
+ }
509
+ fail(`Port ${port} responds at ${baseUrl}, but this script has no live pid file for it.
510
+ Stop the other server or set EUPHONY_PORT to another value.`);
511
+ }
512
+ ensureEuphonyDir();
513
+ if (!existingPid) startViteBackground();
514
+ console.log(`Starting Euphony at ${baseUrl}`);
515
+ console.log(`Log: ${logFile}`);
516
+ for (let i = 0; i < 300; i += 1) {
517
+ if (await requestHead(baseUrl)) {
518
+ console.log(`Euphony is ready at ${baseUrl}`);
519
+ return;
520
+ }
521
+ await new Promise(resolve => setTimeout(resolve, 200));
522
+ }
523
+ fail(`Euphony did not respond at ${baseUrl}. Check ${logFile}`);
524
+ }
525
+
526
+ async function startForeground() {
527
+ ensureEuphonyDir();
528
+ const child = spawnPnpm(
529
+ ['exec', 'vite', '--host', host, '--port', String(port)],
530
+ {
531
+ cwd: euphonyDir,
532
+ env: {
533
+ VITE_EUPHONY_FRONTEND_ONLY: 'true',
534
+ VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES: maxLines
535
+ },
536
+ stdio: 'inherit'
537
+ }
538
+ );
539
+ child.on('exit', code => process.exit(code ?? 0));
540
+ }
541
+
542
+ function killProcessTree(pid) {
543
+ if (isWindows) {
544
+ execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', shell: true });
545
+ } else {
546
+ process.kill(pid, 'SIGTERM');
547
+ }
548
+ }
549
+
550
+ function stop() {
551
+ const pid = trackedOrAdoptedPid();
552
+ if (!pid) {
553
+ if (fs.existsSync(pidFile)) fs.rmSync(pidFile, { force: true });
554
+ console.log('No tracked Euphony process found.');
555
+ return;
556
+ }
557
+ try {
558
+ killProcessTree(pid);
559
+ console.log(`Stopped PID ${pid}`);
560
+ } catch (error) {
561
+ console.log(`Could not stop PID ${pid}: ${error.message}`);
562
+ }
563
+ fs.rmSync(pidFile, { force: true });
564
+ }
565
+
566
+ async function status() {
567
+ const pid = trackedOrAdoptedPid();
568
+ if (await requestHead(baseUrl)) {
569
+ if (pid) {
570
+ console.log(`Euphony responds at ${baseUrl}`);
571
+ console.log(`PID: ${pid}`);
572
+ return;
573
+ }
574
+ console.log(`Port ${port} responds at ${baseUrl}, but this script has no live pid file for it.`);
575
+ process.exitCode = 1;
576
+ return;
577
+ }
578
+ console.log(`Euphony is not responding at ${baseUrl}`);
579
+ if (pid) console.log(`Tracked PID exists but HTTP is not ready: ${pid}`);
580
+ process.exitCode = 1;
581
+ }
582
+
583
+ function openBrowser(url) {
584
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
585
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
586
+ spawn(command, args, { detached: true, stdio: 'ignore' }).unref();
587
+ }
588
+
589
+ async function openCommand(input) {
590
+ await up();
591
+ stageCommand(input || latestSession());
592
+ const url = `${baseUrl}?path=${baseUrl}local-codebuddy/latest.jsonl&no-cache=true`;
593
+ openBrowser(url);
594
+ console.log(`Opened: ${url}`);
595
+ }
596
+
597
+ async function main() {
598
+ const command = process.argv[2] || 'help';
599
+ const arg1 = process.argv[3];
600
+ const arg2 = process.argv[4];
601
+ switch (command) {
602
+ case 'list':
603
+ for (const item of recentSessions().slice(0, 20)) {
604
+ console.log(`${new Date(item.mtimeMs).toISOString()} ${item.file}`);
605
+ }
606
+ break;
607
+ case 'latest':
608
+ console.log(latestSession());
609
+ break;
610
+ case 'convert':
611
+ convertCommand(arg1 || latestSession(), arg2);
612
+ break;
613
+ case 'stage':
614
+ stageCommand(arg1 || latestSession());
615
+ break;
616
+ case 'open':
617
+ await openCommand(arg1);
618
+ break;
619
+ case 'up':
620
+ await up();
621
+ break;
622
+ case 'start':
623
+ await startForeground();
624
+ break;
625
+ case 'status':
626
+ await status();
627
+ break;
628
+ case 'stop':
629
+ stop();
630
+ break;
631
+ case 'restart':
632
+ stop();
633
+ await up();
634
+ break;
635
+ case 'help':
636
+ case '--help':
637
+ case '-h':
638
+ usage();
639
+ break;
640
+ default:
641
+ usage();
642
+ process.exitCode = 1;
643
+ }
644
+ }
645
+
646
+ main().catch(error => fail(error.stack || error.message));