@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,146 @@
1
+ ---
2
+ name: codex-euphony
3
+ description: View local Codex session JSONL logs in OpenAI Euphony. Use when the user asks to inspect, visualize, open, browse, debug, or share their local Codex conversations/sessions/logs/history with Euphony, including finding the latest ~/.codex/sessions rollout file, starting the local Euphony dev server, staging a session for browser loading, or explaining how to load a Codex JSONL file into Euphony.
4
+ ---
5
+
6
+ # Codex Euphony
7
+
8
+ ## Overview
9
+
10
+ Use this skill to inspect local Codex session JSONL files with Euphony.
11
+ The skill manages Euphony as a disposable runtime checkout under `${CODEX_HOME:-$HOME/.codex}/cache/euphony` by default.
12
+ Local Codex rollouts live under `${CODEX_HOME:-$HOME/.codex}/sessions` by default.
13
+ The primary script is `scripts/codex-euphony.mjs` and works on macOS, Linux, and Windows with Node.js 18+.
14
+ `scripts/codex-euphony.sh` is only a Unix compatibility wrapper around the Node script.
15
+
16
+ If the Euphony cache is deleted, the script recreates it on the next command that needs Euphony.
17
+ If `node_modules` is deleted, the script reruns `pnpm install`.
18
+ The script prefers a directly installed `pnpm`; if only Corepack is available, it runs `corepack pnpm` with `COREPACK_INTEGRITY_KEYS=0` for that subprocess to avoid known Corepack pnpm signature bootstrap failures during first startup.
19
+
20
+ ## Workflow
21
+
22
+ 1. Find the latest Codex session without starting Euphony:
23
+
24
+ ```bash
25
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs latest
26
+ ```
27
+
28
+ 2. For normal user requests, open the latest session directly in the default browser:
29
+
30
+ ```bash
31
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs open
32
+ ```
33
+
34
+ This is the default path for requests like "open Euphony", "show my latest Codex session", or "view this session in Euphony".
35
+ It ensures Euphony is installed and running, stages the latest session, prints the load URL, and opens it in the system browser.
36
+
37
+ 3. If the user only wants a URL, or GUI opening is unavailable, print a browser URL instead:
38
+
39
+ ```bash
40
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs url
41
+ ```
42
+
43
+ This ensures Euphony is installed and running, stages the latest session, and prints a URL like:
44
+
45
+ ```text
46
+ http://127.0.0.1:3000/?path=http://127.0.0.1:3000/local-codex/latest.jsonl&no-cache=true
47
+ ```
48
+
49
+ 4. If Euphony is already running and you only need to refresh the staged JSONL, use the lightweight path:
50
+
51
+ ```bash
52
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stage
53
+ ```
54
+
55
+ This avoids restarting Euphony and normally avoids escalation.
56
+
57
+ 5. Check whether Euphony is already running:
58
+
59
+ ```bash
60
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs status
61
+ ```
62
+
63
+ 6. Use staging without starting or opening Euphony only when Euphony is already running:
64
+
65
+ ```bash
66
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stage
67
+ ```
68
+
69
+ This stages the latest session at `$EUPHONY_DIR/public/local-codex/latest.jsonl`.
70
+ By default, staging uses a symlink to the live rollout file on macOS/Linux so browser refreshes can read newly appended turns.
71
+ On Windows, staging defaults to copying because symlink creation often requires extra privileges.
72
+ Set `EUPHONY_STAGE_MODE=copy` to force snapshot copying.
73
+ The skill starts Euphony with `VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES=100000` by default because Codex rollout JSONL files often exceed Euphony's older frontend-only 100-line default.
74
+ Staging is intended for a local Euphony server bound to `127.0.0.1`.
75
+ Codex session files can contain private prompts, file paths, tool outputs, and secrets.
76
+
77
+ The staged local-codex path is needed for URL loading because browser pages cannot fetch arbitrary local files.
78
+ To avoid the copy, use Euphony's `Load local file` button manually.
79
+
80
+ 7. Start Euphony when it is not running:
81
+
82
+ ```bash
83
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs up
84
+ ```
85
+
86
+ Use foreground startup only when explicitly useful for debugging:
87
+
88
+ ```bash
89
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs start
90
+ ```
91
+
92
+ 8. Verify with:
93
+
94
+ ```bash
95
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs status
96
+ ```
97
+
98
+ Report the printed load URL to the user after `HTTP/1.1 200 OK`, or after the script reports that Euphony is listening.
99
+
100
+ 9. Stop Euphony when the user is done:
101
+
102
+ ```bash
103
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stop
104
+ ```
105
+
106
+ The stop command kills the Euphony process tracked by this script's pid file.
107
+
108
+ ## Sandbox And Escalation
109
+
110
+ Do not run heavy commands once in the sandbox just to discover that they need escalation.
111
+
112
+ - `open` is the default user-facing command. It may need escalation in Codex sandbox because it can clone from GitHub, install dependencies, bind a local TCP port, and open a GUI browser.
113
+ - `list`, `latest`, `status`, and `stage` are lightweight local commands and normally do not need escalation.
114
+ - `url`, `up`, `start`, `restart`, and first-time `ensure` may need escalation in Codex sandbox because they can clone from GitHub, install dependencies, or bind a local TCP port.
115
+ - If GUI opening is blocked or not desired, use `url` and report the printed link.
116
+
117
+ If startup fails with `listen EPERM`, rerun the same startup command with escalated permissions because this environment may block local TCP listeners inside the sandbox.
118
+
119
+ ## Manual Loading
120
+
121
+ If staging is not desired, tell the user to open `http://127.0.0.1:3000/`, click `Load local file`, and choose the JSONL path from `latest`.
122
+ On macOS, `Cmd+Shift+G` in the file picker allows pasting the path directly.
123
+
124
+ Do not tell the user to paste a local absolute path into Euphony's top URL box. Browser pages cannot fetch arbitrary `file://` or `/Users/...` paths directly.
125
+
126
+ ## Script Commands
127
+
128
+ ```bash
129
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs list
130
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs latest
131
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs status
132
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs ensure
133
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stage [session-jsonl]
134
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs url [session-jsonl]
135
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs open [session-jsonl]
136
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs up
137
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs start
138
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs stop
139
+ node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs restart
140
+ ```
141
+
142
+ Use environment overrides only when needed:
143
+
144
+ ```bash
145
+ EUPHONY_DIR=/path/to/euphony CODEX_SESSIONS_DIR=/path/to/sessions node ${CODEX_HOME:-$HOME/.codex}/skills/codex-euphony/scripts/codex-euphony.mjs latest
146
+ ```
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Codex Euphony"
3
+ short_description: "Open local Codex sessions in Euphony."
4
+ default_prompt: "Open my latest local Codex session in Euphony."
@@ -0,0 +1,468 @@
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 codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
11
+ const sessionsDir = process.env.CODEX_SESSIONS_DIR || path.join(codexHome, 'sessions');
12
+ const euphonyDir = process.env.EUPHONY_DIR || path.join(codexHome, 'cache', 'euphony');
13
+ const euphonyRepo = process.env.EUPHONY_REPO || 'https://github.com/openai/euphony.git';
14
+ const host = process.env.EUPHONY_HOST || '127.0.0.1';
15
+ const port = Number(process.env.EUPHONY_PORT || '3000');
16
+ const baseUrl = `http://${host}:${port}/`;
17
+ const runDir = process.env.EUPHONY_RUN_DIR || path.join(euphonyDir, '.codex-euphony');
18
+ const pidFile = path.join(runDir, 'vite.pid');
19
+ const logFile = path.join(runDir, 'vite.log');
20
+ const maxLines = process.env.EUPHONY_FRONTEND_ONLY_MAX_LINES || '100000';
21
+ const stageMode = process.env.EUPHONY_STAGE_MODE || (isWindows ? 'copy' : 'symlink');
22
+ const stagedDir = path.join(euphonyDir, 'public', 'local-codex');
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]
28
+
29
+ Commands:
30
+ list List recent Codex session JSONL files.
31
+ latest Print the newest Codex session JSONL path.
32
+ status Check whether Euphony responds.
33
+ ensure Ensure the Euphony runtime checkout and dependencies exist.
34
+ stage [file] Stage a session JSONL into Euphony public/local-codex/latest.jsonl and print a load URL.
35
+ url [file] Ensure Euphony is running, stage a session, and print the load URL.
36
+ open [file] Ensure Euphony is running, stage a session, and open the load URL in the browser.
37
+ up Start Euphony in the background if it is not already running.
38
+ start Start Euphony Vite dev server in the foreground.
39
+ stop Stop the Euphony Vite server started by this script.
40
+ restart Stop then start Euphony in the background.
41
+
42
+ Environment:
43
+ CODEX_HOME Default: ~/.codex
44
+ CODEX_SESSIONS_DIR Default: $CODEX_HOME/sessions
45
+ EUPHONY_DIR Default: $CODEX_HOME/cache/euphony
46
+ EUPHONY_HOST Default: 127.0.0.1
47
+ EUPHONY_PORT Default: 3000
48
+ EUPHONY_RUN_DIR Default: $EUPHONY_DIR/.codex-euphony
49
+ EUPHONY_REPO Default: https://github.com/openai/euphony.git
50
+ EUPHONY_STAGE_MODE Default: ${isWindows ? 'copy on Windows, symlink elsewhere' : 'symlink'}
51
+ EUPHONY_FRONTEND_ONLY_MAX_LINES
52
+ Default: 100000`);
53
+ }
54
+
55
+ function fail(message) {
56
+ console.error(message);
57
+ process.exit(1);
58
+ }
59
+
60
+ function commandExists(command) {
61
+ try {
62
+ if (isWindows) {
63
+ execFileSync('where', [command], { stdio: 'ignore', shell: true });
64
+ } else {
65
+ execFileSync('which', [command], { stdio: 'ignore' });
66
+ }
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function requireCommand(command) {
74
+ if (!commandExists(command)) fail(`${command} is required for this command.`);
75
+ }
76
+
77
+ function run(command, args, options = {}) {
78
+ return execFileSync(command, args, {
79
+ ...options,
80
+ shell: isWindows && command === 'corepack',
81
+ stdio: options.stdio || 'inherit'
82
+ });
83
+ }
84
+
85
+ function packageRunner() {
86
+ if (commandExists('pnpm')) {
87
+ return { command: 'pnpm', argsPrefix: [], env: process.env, shell: isWindows };
88
+ }
89
+ requireCommand('corepack');
90
+ return {
91
+ command: 'corepack',
92
+ argsPrefix: ['pnpm'],
93
+ env: {
94
+ ...process.env,
95
+ COREPACK_INTEGRITY_KEYS: process.env.COREPACK_INTEGRITY_KEYS || '0'
96
+ },
97
+ shell: isWindows
98
+ };
99
+ }
100
+
101
+ function runPnpm(args, options = {}) {
102
+ const runner = packageRunner();
103
+ if (runner.command === 'corepack' && !process.env.COREPACK_INTEGRITY_KEYS) {
104
+ console.log('Using Corepack with COREPACK_INTEGRITY_KEYS=0 to avoid known pnpm signature bootstrap failures.');
105
+ }
106
+ return execFileSync(runner.command, [...runner.argsPrefix, ...args], {
107
+ ...options,
108
+ env: runner.env,
109
+ shell: runner.shell,
110
+ stdio: options.stdio || 'inherit'
111
+ });
112
+ }
113
+
114
+ function spawnPnpm(args, options = {}) {
115
+ const runner = packageRunner();
116
+ return spawn(runner.command, [...runner.argsPrefix, ...args], {
117
+ ...options,
118
+ env: {
119
+ ...runner.env,
120
+ ...(options.env || {})
121
+ },
122
+ shell: runner.shell
123
+ });
124
+ }
125
+
126
+ function requireSessionsDir() {
127
+ if (!fs.existsSync(sessionsDir) || !fs.statSync(sessionsDir).isDirectory()) {
128
+ fail(`Codex sessions directory not found: ${sessionsDir}`);
129
+ }
130
+ }
131
+
132
+ function patchEuphonyFrontendLimit() {
133
+ const apiManager = path.join(euphonyDir, 'src', 'utils', 'api-manager.ts');
134
+ if (!fs.existsSync(apiManager)) return;
135
+ const oldText = fs.readFileSync(apiManager, 'utf8');
136
+ if (oldText.includes('VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES')) return;
137
+ const needle =
138
+ '// The maximum number of lines in a JSONL file to read in frontend-only mode\nconst FRONTEND_ONLY_MODE_MAX_LINES = 100;';
139
+ if (!oldText.includes(needle)) return;
140
+ const replacement =
141
+ "// The maximum number of lines in a JSONL file to read in frontend-only mode.\n" +
142
+ '// Codex rollout files are event streams and routinely exceed 100 lines, so keep\n' +
143
+ '// the default high while allowing local deployments to lower it.\n' +
144
+ 'const FRONTEND_ONLY_MODE_MAX_LINES = Number.parseInt(\n' +
145
+ " (import.meta.env.VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES as string) || '100000',\n" +
146
+ ' 10\n' +
147
+ ');';
148
+ fs.writeFileSync(apiManager, oldText.replace(needle, replacement));
149
+ }
150
+
151
+ function ensureEuphonyDir() {
152
+ const packageJson = path.join(euphonyDir, 'package.json');
153
+ if (fs.existsSync(packageJson)) {
154
+ patchEuphonyFrontendLimit();
155
+ if (!fs.existsSync(path.join(euphonyDir, 'node_modules'))) {
156
+ console.log(`Installing Euphony dependencies in ${euphonyDir}...`);
157
+ runPnpm(['install'], { cwd: euphonyDir });
158
+ }
159
+ return;
160
+ }
161
+
162
+ if (fs.existsSync(euphonyDir)) {
163
+ fail(`EUPHONY_DIR exists but is not an Euphony checkout: ${euphonyDir}
164
+ Remove it or set EUPHONY_DIR to another path.`);
165
+ }
166
+
167
+ requireCommand('git');
168
+ fs.mkdirSync(path.dirname(euphonyDir), { recursive: true });
169
+ console.log(`Cloning Euphony into ${euphonyDir}...`);
170
+ run('git', ['clone', euphonyRepo, euphonyDir]);
171
+ patchEuphonyFrontendLimit();
172
+ console.log(`Installing Euphony dependencies in ${euphonyDir}...`);
173
+ runPnpm(['install'], { cwd: euphonyDir });
174
+ }
175
+
176
+ function walkJsonlFiles(dir, out = []) {
177
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
178
+ if (entry.name === '.DS_Store') continue;
179
+ const fullPath = path.join(dir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ walkJsonlFiles(fullPath, out);
182
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
183
+ out.push(fullPath);
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function recentSessions() {
190
+ requireSessionsDir();
191
+ return walkJsonlFiles(sessionsDir)
192
+ .map(file => ({ file, mtimeMs: fs.statSync(file).mtimeMs }))
193
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
194
+ }
195
+
196
+ function latestSession() {
197
+ const [latest] = recentSessions();
198
+ if (!latest) fail(`No Codex session JSONL files found under: ${sessionsDir}`);
199
+ return latest.file;
200
+ }
201
+
202
+ function stageSession(input = latestSession()) {
203
+ ensureEuphonyDir();
204
+ const source = path.resolve(input);
205
+ if (!fs.existsSync(source)) fail(`Session file not found: ${source}`);
206
+ fs.mkdirSync(stagedDir, { recursive: true });
207
+ if (stageMode === 'symlink') {
208
+ try {
209
+ fs.rmSync(stagedJsonl, { force: true });
210
+ fs.symlinkSync(source, stagedJsonl, 'file');
211
+ } catch {
212
+ fs.copyFileSync(source, stagedJsonl);
213
+ }
214
+ } else {
215
+ fs.copyFileSync(source, stagedJsonl);
216
+ }
217
+ fs.writeFileSync(stagedSource, `${source}\n`);
218
+ const url = `${baseUrl}?path=${baseUrl}local-codex/latest.jsonl&no-cache=true`;
219
+ console.log(`Staged: ${source}`);
220
+ console.log(`Open: ${url}`);
221
+ return url;
222
+ }
223
+
224
+ function requestHead(url, timeoutMs = 2000) {
225
+ return new Promise(resolve => {
226
+ const request = http.request(url, { method: 'HEAD', timeout: timeoutMs }, response => {
227
+ response.resume();
228
+ resolve(response.statusCode >= 200 && response.statusCode < 500);
229
+ });
230
+ request.on('timeout', () => {
231
+ request.destroy();
232
+ resolve(false);
233
+ });
234
+ request.on('error', () => resolve(false));
235
+ request.end();
236
+ });
237
+ }
238
+
239
+ function readTrackedPid() {
240
+ if (!fs.existsSync(pidFile)) return null;
241
+ const text = fs.readFileSync(pidFile, 'utf8').trim();
242
+ if (!/^\d+$/.test(text)) return null;
243
+ return Number(text);
244
+ }
245
+
246
+ function pidExists(pid) {
247
+ if (!pid) return false;
248
+ try {
249
+ process.kill(pid, 0);
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ function trackedPid() {
257
+ const pid = readTrackedPid();
258
+ return pidExists(pid) ? pid : null;
259
+ }
260
+
261
+ function legacyCheckoutPids() {
262
+ if (isWindows || !commandExists('lsof')) return [];
263
+ try {
264
+ const output = execFileSync('lsof', ['-nP', `-tiTCP:${port}`, '-sTCP:LISTEN'], {
265
+ encoding: 'utf8',
266
+ stdio: ['ignore', 'pipe', 'ignore']
267
+ });
268
+ return output
269
+ .split(/\r?\n/)
270
+ .filter(Boolean)
271
+ .filter(pid => {
272
+ try {
273
+ const cwdOutput = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
274
+ encoding: 'utf8',
275
+ stdio: ['ignore', 'pipe', 'ignore']
276
+ });
277
+ const cwd = cwdOutput
278
+ .split(/\r?\n/)
279
+ .find(line => line.startsWith('n'))
280
+ ?.slice(1);
281
+ return cwd === euphonyDir;
282
+ } catch {
283
+ return false;
284
+ }
285
+ })
286
+ .map(Number);
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+
292
+ function trackedOrAdoptedPid() {
293
+ const pid = trackedPid();
294
+ if (pid) return pid;
295
+ const [legacyPid] = legacyCheckoutPids();
296
+ if (!legacyPid) return null;
297
+ fs.mkdirSync(runDir, { recursive: true });
298
+ fs.writeFileSync(pidFile, `${legacyPid}\n`);
299
+ return legacyPid;
300
+ }
301
+
302
+ function startViteBackground() {
303
+ fs.mkdirSync(runDir, { recursive: true });
304
+ const logFd = fs.openSync(logFile, 'a');
305
+ const child = spawnPnpm(['exec', 'vite', '--host', host, '--port', String(port)], {
306
+ cwd: euphonyDir,
307
+ detached: true,
308
+ env: {
309
+ VITE_EUPHONY_FRONTEND_ONLY: 'true',
310
+ VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES: maxLines
311
+ },
312
+ stdio: ['ignore', logFd, logFd]
313
+ });
314
+ child.unref();
315
+ fs.writeFileSync(pidFile, `${child.pid}\n`);
316
+ }
317
+
318
+ async function up() {
319
+ const existingPid = trackedOrAdoptedPid();
320
+ if (await requestHead(baseUrl)) {
321
+ if (existingPid) {
322
+ ensureEuphonyDir();
323
+ console.log(`Euphony responds at ${baseUrl}`);
324
+ console.log(`PID: ${existingPid}`);
325
+ return;
326
+ }
327
+ fail(`Port ${port} responds at ${baseUrl}, but this script has no live pid file for it.
328
+ Stop the other server or set EUPHONY_PORT to another value.`);
329
+ }
330
+ ensureEuphonyDir();
331
+ if (!existingPid) startViteBackground();
332
+ console.log(`Starting Euphony at ${baseUrl}`);
333
+ console.log(`Log: ${logFile}`);
334
+ for (let i = 0; i < 300; i += 1) {
335
+ if (await requestHead(baseUrl)) {
336
+ console.log(`Euphony is ready at ${baseUrl}`);
337
+ return;
338
+ }
339
+ await new Promise(resolve => setTimeout(resolve, 200));
340
+ }
341
+ fail(`Euphony did not respond at ${baseUrl}. Check ${logFile}`);
342
+ }
343
+
344
+ async function startForeground() {
345
+ ensureEuphonyDir();
346
+ const child = spawnPnpm(['exec', 'vite', '--host', host, '--port', String(port)], {
347
+ cwd: euphonyDir,
348
+ env: {
349
+ VITE_EUPHONY_FRONTEND_ONLY: 'true',
350
+ VITE_EUPHONY_FRONTEND_ONLY_MAX_LINES: maxLines
351
+ },
352
+ stdio: 'inherit'
353
+ });
354
+ child.on('exit', code => process.exit(code ?? 0));
355
+ }
356
+
357
+ function killProcessTree(pid) {
358
+ if (isWindows) {
359
+ execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', shell: true });
360
+ } else {
361
+ process.kill(pid, 'SIGTERM');
362
+ }
363
+ }
364
+
365
+ function stop() {
366
+ const pid = trackedOrAdoptedPid();
367
+ if (!pid) {
368
+ if (fs.existsSync(pidFile)) fs.rmSync(pidFile, { force: true });
369
+ console.log('No tracked Euphony process found.');
370
+ return;
371
+ }
372
+ try {
373
+ killProcessTree(pid);
374
+ console.log(`Stopped PID ${pid}`);
375
+ } catch (error) {
376
+ console.log(`Could not stop PID ${pid}: ${error.message}`);
377
+ }
378
+ fs.rmSync(pidFile, { force: true });
379
+ }
380
+
381
+ async function status() {
382
+ const pid = trackedOrAdoptedPid();
383
+ if (await requestHead(baseUrl)) {
384
+ if (pid) {
385
+ console.log(`Euphony responds at ${baseUrl}`);
386
+ console.log(`PID: ${pid}`);
387
+ return;
388
+ }
389
+ console.log(`Port ${port} responds at ${baseUrl}, but this script has no live pid file for it.`);
390
+ process.exitCode = 1;
391
+ return;
392
+ }
393
+ console.log(`Euphony is not responding at ${baseUrl}`);
394
+ if (pid) console.log(`Tracked PID exists but HTTP is not ready: ${pid}`);
395
+ process.exitCode = 1;
396
+ }
397
+
398
+ function openBrowser(url) {
399
+ const command = process.platform === 'darwin' ? 'open' : isWindows ? 'cmd' : 'xdg-open';
400
+ const args = isWindows ? ['/c', 'start', '', url] : [url];
401
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', shell: false });
402
+ child.on('error', () => {
403
+ console.log(`Open this URL in a browser: ${url}`);
404
+ });
405
+ child.unref();
406
+ }
407
+
408
+ async function openCommand(input) {
409
+ await up();
410
+ const url = stageSession(input || latestSession());
411
+ openBrowser(url);
412
+ console.log(`Opened: ${url}`);
413
+ }
414
+
415
+ async function main() {
416
+ const command = process.argv[2] || 'help';
417
+ const arg1 = process.argv[3];
418
+ switch (command) {
419
+ case 'list':
420
+ for (const item of recentSessions().slice(0, 20)) {
421
+ console.log(`${new Date(item.mtimeMs).toISOString()} ${item.file}`);
422
+ }
423
+ break;
424
+ case 'latest':
425
+ console.log(latestSession());
426
+ break;
427
+ case 'status':
428
+ await status();
429
+ break;
430
+ case 'ensure':
431
+ ensureEuphonyDir();
432
+ console.log(`Euphony is ready at ${euphonyDir}`);
433
+ break;
434
+ case 'stage':
435
+ stageSession(arg1 || latestSession());
436
+ break;
437
+ case 'url':
438
+ await up();
439
+ stageSession(arg1 || latestSession());
440
+ break;
441
+ case 'open':
442
+ await openCommand(arg1);
443
+ break;
444
+ case 'up':
445
+ await up();
446
+ break;
447
+ case 'start':
448
+ await startForeground();
449
+ break;
450
+ case 'stop':
451
+ stop();
452
+ break;
453
+ case 'restart':
454
+ stop();
455
+ await up();
456
+ break;
457
+ case 'help':
458
+ case '--help':
459
+ case '-h':
460
+ usage();
461
+ break;
462
+ default:
463
+ usage();
464
+ process.exitCode = 1;
465
+ }
466
+ }
467
+
468
+ main().catch(error => fail(error.stack || error.message));
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
5
+ exec node "$script_dir/codex-euphony.mjs" "$@"