@bramblex/codex-workbench 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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/codex-workbench +4 -0
- package/package.json +42 -0
- package/src/cli.js +751 -0
- package/src/codex-bin.js +125 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bramblex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# codex-workbench
|
|
2
|
+
|
|
3
|
+
A small terminal workbench for browsing and managing local Codex sessions.
|
|
4
|
+
|
|
5
|
+
It reads Codex session JSONL files, groups sessions by project directory, and lets you inspect, rename, annotate, resume, fork, archive, unarchive, or delete sessions from either a command-line interface or an interactive terminal UI.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @bramblex/codex-workbench
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
codex-workbench expects the Codex CLI to be available from your shell. You can check what executable will be used with:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
codex-workbench doctor
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For local development:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then run the CLI through Node:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
node bin/codex-workbench --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
To expose the `codex-workbench` command locally:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm link
|
|
35
|
+
codex-workbench list
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
codex-workbench [ui]
|
|
42
|
+
codex-workbench doctor
|
|
43
|
+
codex-workbench list [--json] [--cwd <dir>] [--all]
|
|
44
|
+
codex-workbench show <session>
|
|
45
|
+
codex-workbench rename <session> <name>
|
|
46
|
+
codex-workbench note <session> <note>
|
|
47
|
+
codex-workbench resume <session> [prompt...]
|
|
48
|
+
codex-workbench fork <session>
|
|
49
|
+
codex-workbench archive <session>
|
|
50
|
+
codex-workbench unarchive <session>
|
|
51
|
+
codex-workbench delete <session> [--force]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run without arguments to open the interactive UI:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
codex-workbench
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Use `list` to find sessions:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
codex-workbench list
|
|
64
|
+
codex-workbench list --json
|
|
65
|
+
codex-workbench list --cwd /path/to/project
|
|
66
|
+
codex-workbench list --all
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use `doctor` to check which Codex executable the CLI will launch:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
codex-workbench doctor
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Most commands accept a full session id, a unique prefix, a saved name, or a session filename.
|
|
76
|
+
|
|
77
|
+
## Interactive UI
|
|
78
|
+
|
|
79
|
+
The UI groups sessions by working directory, with sessions above and details below. When you resume a session, Codex temporarily takes over the terminal; when Codex exits, codex-workbench redraws the UI.
|
|
80
|
+
|
|
81
|
+
Common keys:
|
|
82
|
+
|
|
83
|
+
- `Enter` or `r`: resume the selected session in Codex
|
|
84
|
+
- `f`: fork the selected session
|
|
85
|
+
- `v`: print session details and exit
|
|
86
|
+
- `n`: rename the selected session
|
|
87
|
+
- `o`: add or edit a note
|
|
88
|
+
- `a`: archive the selected session
|
|
89
|
+
- `d`: delete the selected session
|
|
90
|
+
- `Tab`: switch focus between the session list and details pane
|
|
91
|
+
- `Left`/`Right` or `h`/`l`: switch project group
|
|
92
|
+
- `q`, `Esc`, or `Ctrl+C`: quit
|
|
93
|
+
|
|
94
|
+
## Environment
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
CODEX_HOME # default: ~/.codex
|
|
98
|
+
CODEX_SESSIONS_DIR # default: $CODEX_HOME/sessions
|
|
99
|
+
CODEX_WORKBENCH_META # default: $CODEX_HOME/codex-workbench.json
|
|
100
|
+
CODEX_BIN # default: codex from shell PATH
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`CODEX_WORKBENCH_META` stores local workbench metadata such as custom names and notes. Session content remains in the Codex sessions directory.
|
|
104
|
+
|
|
105
|
+
By default, codex-workbench launches `codex` through your shell so your normal shell `PATH` applies. Set `CODEX_BIN` if you want to force a specific executable path.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
npm test
|
|
111
|
+
npm pack --dry-run
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Project layout:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
bin/codex-workbench # executable entrypoint
|
|
118
|
+
src/cli.js # main CLI and UI implementation
|
|
119
|
+
src/codex-bin.js # Codex executable discovery
|
|
120
|
+
test/smoke.js # smoke tests
|
|
121
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bramblex/codex-workbench",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal workbench for browsing and managing local Codex sessions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/bramblex/codex-workbench.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/bramblex/codex-workbench/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/bramblex/codex-workbench#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"codex",
|
|
16
|
+
"cli",
|
|
17
|
+
"terminal",
|
|
18
|
+
"sessions",
|
|
19
|
+
"tui"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"codex-workbench": "bin/codex-workbench"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin/",
|
|
29
|
+
"src/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --check src/cli.js && node --check src/codex-bin.js && node test/codex-bin.test.js && node test/smoke.js"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"blessed": "^0.1.81"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const blessed = require('blessed');
|
|
8
|
+
const { spawnSync, spawn } = require('child_process');
|
|
9
|
+
const { inspectCodexBin, resolveCodexBin } = require('./codex-bin');
|
|
10
|
+
|
|
11
|
+
const HOME = os.homedir();
|
|
12
|
+
const CODEX_HOME = process.env.CODEX_HOME || path.join(HOME, '.codex');
|
|
13
|
+
const SESSIONS_DIR = process.env.CODEX_SESSIONS_DIR || path.join(CODEX_HOME, 'sessions');
|
|
14
|
+
const META_PATH = process.env.CODEX_WORKBENCH_META || process.env.CSM_META || path.join(CODEX_HOME, 'codex-workbench.json');
|
|
15
|
+
|
|
16
|
+
function usage() {
|
|
17
|
+
console.log(`codex-workbench
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
codex-workbench [ui]
|
|
21
|
+
codex-workbench doctor
|
|
22
|
+
codex-workbench list [--json] [--cwd <dir>] [--all]
|
|
23
|
+
codex-workbench show <session>
|
|
24
|
+
codex-workbench rename <session> <name>
|
|
25
|
+
codex-workbench note <session> <note>
|
|
26
|
+
codex-workbench resume <session> [prompt...]
|
|
27
|
+
codex-workbench fork <session>
|
|
28
|
+
codex-workbench archive <session>
|
|
29
|
+
codex-workbench unarchive <session>
|
|
30
|
+
codex-workbench delete <session> [--force]
|
|
31
|
+
|
|
32
|
+
Environment:
|
|
33
|
+
CODEX_HOME default: ~/.codex
|
|
34
|
+
CODEX_SESSIONS_DIR default: $CODEX_HOME/sessions
|
|
35
|
+
CODEX_WORKBENCH_META default: $CODEX_HOME/codex-workbench.json
|
|
36
|
+
CODEX_BIN default: codex from shell PATH
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJson(file, fallback) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeJson(file, value) {
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walk(dir, out = []) {
|
|
54
|
+
let entries = [];
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const full = path.join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory()) walk(full, out);
|
|
63
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function textFromContent(content) {
|
|
69
|
+
if (!Array.isArray(content)) return '';
|
|
70
|
+
return content
|
|
71
|
+
.filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
|
|
72
|
+
.map((item) => item.text || '')
|
|
73
|
+
.join(' ')
|
|
74
|
+
.replace(/\s+/g, ' ')
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isNoiseUserText(text) {
|
|
79
|
+
return text.includes('<environment_context>') || text.includes('<permissions instructions>');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseSession(file) {
|
|
83
|
+
const stat = fs.statSync(file);
|
|
84
|
+
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
85
|
+
const lines = raw ? raw.split(/\n/) : [];
|
|
86
|
+
let meta = {};
|
|
87
|
+
const messages = [];
|
|
88
|
+
let turns = 0;
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
let row;
|
|
92
|
+
try {
|
|
93
|
+
row = JSON.parse(line);
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (row.type === 'session_meta') meta = row.payload || {};
|
|
98
|
+
if (row.type === 'response_item' && row.payload && row.payload.type === 'message') {
|
|
99
|
+
const msg = row.payload;
|
|
100
|
+
if (msg.role === 'developer') continue;
|
|
101
|
+
const text = textFromContent(msg.content);
|
|
102
|
+
if (!text) continue;
|
|
103
|
+
if (msg.role === 'user' && isNoiseUserText(text)) continue;
|
|
104
|
+
messages.push({
|
|
105
|
+
role: msg.role,
|
|
106
|
+
phase: msg.phase || '',
|
|
107
|
+
text,
|
|
108
|
+
});
|
|
109
|
+
if (msg.role === 'user') turns += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const id = meta.id || path.basename(file, '.jsonl').split('-').slice(-5).join('-');
|
|
114
|
+
const firstUser = messages.find((msg) => msg.role === 'user');
|
|
115
|
+
const lastUser = [...messages].reverse().find((msg) => msg.role === 'user');
|
|
116
|
+
const lastAssistant = [...messages].reverse().find((msg) => msg.role === 'assistant');
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
file,
|
|
121
|
+
cwd: meta.cwd || '(unknown)',
|
|
122
|
+
startedAt: meta.timestamp || null,
|
|
123
|
+
updatedAt: stat.mtime.toISOString(),
|
|
124
|
+
cliVersion: meta.cli_version || '',
|
|
125
|
+
source: meta.source || '',
|
|
126
|
+
provider: meta.model_provider || '',
|
|
127
|
+
turns,
|
|
128
|
+
first: firstUser ? firstUser.text : '',
|
|
129
|
+
last: lastUser ? lastUser.text : '',
|
|
130
|
+
lastAssistant: lastAssistant ? lastAssistant.text : '',
|
|
131
|
+
messages,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadMeta() {
|
|
136
|
+
const data = readJson(META_PATH, { sessions: {} });
|
|
137
|
+
if (!data.sessions) data.sessions = {};
|
|
138
|
+
return data;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function listSessions() {
|
|
142
|
+
const meta = loadMeta();
|
|
143
|
+
return walk(SESSIONS_DIR)
|
|
144
|
+
.map(parseSession)
|
|
145
|
+
.map((session) => ({ ...session, ...(meta.sessions[session.id] || {}) }))
|
|
146
|
+
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveSession(query, sessions = listSessions()) {
|
|
150
|
+
if (!query) throw new Error('Missing session. Run `codex-workbench list` to find a session id.');
|
|
151
|
+
const matches = sessions.filter((session) => {
|
|
152
|
+
return session.id === query ||
|
|
153
|
+
session.id.startsWith(query) ||
|
|
154
|
+
session.name === query ||
|
|
155
|
+
path.basename(session.file) === query;
|
|
156
|
+
});
|
|
157
|
+
if (matches.length === 1) return matches[0];
|
|
158
|
+
if (matches.length === 0) throw new Error(`No session matched: ${query}`);
|
|
159
|
+
throw new Error(`Ambiguous session: ${query}\n${matches.map((s) => ` ${s.id} ${s.name || ''}`).join('\n')}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shortId(id) {
|
|
163
|
+
return id.slice(0, 13);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function localTime(iso) {
|
|
167
|
+
if (!iso) return '';
|
|
168
|
+
return new Date(iso).toLocaleString();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function truncate(text, width) {
|
|
172
|
+
if (!text) return '';
|
|
173
|
+
return text.length > width ? text.slice(0, Math.max(0, width - 1)) + '...' : text;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function printList(sessions, opts = {}) {
|
|
177
|
+
const filtered = sessions.filter((session) => {
|
|
178
|
+
if (!opts.all && session.archived) return false;
|
|
179
|
+
if (opts.cwd) return path.resolve(session.cwd) === path.resolve(opts.cwd);
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
if (opts.json) {
|
|
183
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const groups = new Map();
|
|
187
|
+
for (const session of filtered) {
|
|
188
|
+
if (!groups.has(session.cwd)) groups.set(session.cwd, []);
|
|
189
|
+
groups.get(session.cwd).push(session);
|
|
190
|
+
}
|
|
191
|
+
for (const [cwd, group] of groups) {
|
|
192
|
+
console.log(`\n${cwd}`);
|
|
193
|
+
for (const session of group) {
|
|
194
|
+
const label = session.name || truncate(session.first || session.last || '(no prompt)', 56);
|
|
195
|
+
const flags = [session.archived ? 'archived' : '', session.note ? 'note' : ''].filter(Boolean).join(',');
|
|
196
|
+
console.log(` ${shortId(session.id)} ${localTime(session.updatedAt)} ${String(session.turns).padStart(2)} turns ${flags ? `[${flags}] ` : ''}${label}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!filtered.length) console.log('No sessions found.');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function printShow(session) {
|
|
203
|
+
console.log(`${session.name || '(unnamed)'} ${session.archived ? '[archived]' : ''}`);
|
|
204
|
+
console.log(`id: ${session.id}`);
|
|
205
|
+
console.log(`cwd: ${session.cwd}`);
|
|
206
|
+
console.log(`started: ${localTime(session.startedAt)}`);
|
|
207
|
+
console.log(`updated: ${localTime(session.updatedAt)}`);
|
|
208
|
+
console.log(`file: ${session.file}`);
|
|
209
|
+
console.log(`turns: ${session.turns}`);
|
|
210
|
+
if (session.note) console.log(`note: ${session.note}`);
|
|
211
|
+
console.log('\nMessages:');
|
|
212
|
+
for (const msg of session.messages) {
|
|
213
|
+
if (msg.role === 'developer') continue;
|
|
214
|
+
const prefix = msg.role === 'assistant' ? 'A' : msg.role === 'user' ? 'U' : msg.role.slice(0, 1).toUpperCase();
|
|
215
|
+
console.log(` ${prefix}: ${truncate(msg.text, 180)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function printDoctor() {
|
|
220
|
+
const result = inspectCodexBin();
|
|
221
|
+
console.log('codex-workbench doctor');
|
|
222
|
+
console.log(`status: ${result.ok ? 'ok' : 'error'}`);
|
|
223
|
+
if (result.path) console.log(`codex: ${result.path}`);
|
|
224
|
+
if (result.source) console.log(`source: ${result.source}`);
|
|
225
|
+
if (result.error) console.log(`error: ${result.error}`);
|
|
226
|
+
console.log('\nChecks:');
|
|
227
|
+
for (const check of result.checks) {
|
|
228
|
+
const parts = [
|
|
229
|
+
check.source,
|
|
230
|
+
check.mode ? `mode=${check.mode}` : '',
|
|
231
|
+
check.shell ? `shell=${check.shell}` : '',
|
|
232
|
+
check.path ? `path=${check.path}` : '',
|
|
233
|
+
`executable=${check.executable ? 'yes' : 'no'}`,
|
|
234
|
+
].filter(Boolean);
|
|
235
|
+
console.log(` - ${parts.join(' ')}`);
|
|
236
|
+
}
|
|
237
|
+
if (!result.ok) process.exitCode = 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function updateMetadata(session, patch) {
|
|
241
|
+
const meta = loadMeta();
|
|
242
|
+
meta.sessions[session.id] = { ...(meta.sessions[session.id] || {}), ...patch };
|
|
243
|
+
meta.updatedAt = new Date().toISOString();
|
|
244
|
+
writeJson(META_PATH, meta);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function usableCwd(dir) {
|
|
248
|
+
const candidates = [dir, process.cwd(), HOME];
|
|
249
|
+
for (const candidate of candidates) {
|
|
250
|
+
if (!candidate || candidate === '(unknown)') continue;
|
|
251
|
+
try {
|
|
252
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
253
|
+
} catch {
|
|
254
|
+
// Try the next fallback.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return HOME;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function shellQuote(value) {
|
|
261
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function commandShell() {
|
|
265
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
266
|
+
try {
|
|
267
|
+
fs.accessSync(shell, fs.constants.X_OK);
|
|
268
|
+
return shell;
|
|
269
|
+
} catch {
|
|
270
|
+
return '/bin/sh';
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function codexCommand(command, session, args = [], inherit = false) {
|
|
275
|
+
const executable = resolveCodexBin();
|
|
276
|
+
const argv = [executable, command, session.id, ...args];
|
|
277
|
+
const shellCommand = `exec ${argv.map(shellQuote).join(' ')}`;
|
|
278
|
+
const cwd = usableCwd(session.cwd);
|
|
279
|
+
const shell = commandShell();
|
|
280
|
+
if (inherit) {
|
|
281
|
+
const child = spawn(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
|
|
282
|
+
child.on('error', (err) => {
|
|
283
|
+
console.error(`error: failed to start codex: ${err.message}`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
});
|
|
286
|
+
child.on('exit', (code, signal) => {
|
|
287
|
+
if (signal) process.kill(process.pid, signal);
|
|
288
|
+
process.exit(code || 0);
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const result = spawnSync(shell, ['-lc', shellCommand], { stdio: 'inherit', cwd, env: process.env });
|
|
293
|
+
if (result.error) throw new Error(`failed to start codex: ${result.error.message}`);
|
|
294
|
+
const status = result.status || 0;
|
|
295
|
+
process.exitCode = status;
|
|
296
|
+
return status;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function parseFlags(args) {
|
|
300
|
+
const out = { _: [] };
|
|
301
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
302
|
+
const arg = args[i];
|
|
303
|
+
if (arg === '--json') out.json = true;
|
|
304
|
+
else if (arg === '--all') out.all = true;
|
|
305
|
+
else if (arg === '--force') out.force = true;
|
|
306
|
+
else if (arg === '--cwd') {
|
|
307
|
+
if (i + 1 >= args.length) throw new Error('--cwd requires a directory.');
|
|
308
|
+
out.cwd = args[++i];
|
|
309
|
+
}
|
|
310
|
+
else out._.push(arg);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function ui() {
|
|
316
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
317
|
+
return printList(listSessions());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let sessions = [];
|
|
321
|
+
let groups = [];
|
|
322
|
+
let groupIndex = 0;
|
|
323
|
+
let selected = 0;
|
|
324
|
+
let message = '';
|
|
325
|
+
let syncingList = false;
|
|
326
|
+
|
|
327
|
+
const screen = blessed.screen({
|
|
328
|
+
smartCSR: true,
|
|
329
|
+
fullUnicode: true,
|
|
330
|
+
title: 'Codex Workbench',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const header = blessed.box({
|
|
334
|
+
parent: screen,
|
|
335
|
+
top: 0,
|
|
336
|
+
left: 0,
|
|
337
|
+
right: 0,
|
|
338
|
+
height: 3,
|
|
339
|
+
padding: { left: 1, right: 1 },
|
|
340
|
+
style: { fg: 'white', bg: 'blue' },
|
|
341
|
+
content: 'Codex Workbench',
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const groupsBar = blessed.box({
|
|
345
|
+
parent: screen,
|
|
346
|
+
label: ' Projects ',
|
|
347
|
+
top: 3,
|
|
348
|
+
left: 0,
|
|
349
|
+
right: 0,
|
|
350
|
+
height: 3,
|
|
351
|
+
border: 'line',
|
|
352
|
+
padding: { left: 1, right: 1 },
|
|
353
|
+
tags: true,
|
|
354
|
+
parseTags: true,
|
|
355
|
+
style: {
|
|
356
|
+
border: { fg: 'green' },
|
|
357
|
+
fg: 'white',
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const sessionsList = blessed.list({
|
|
362
|
+
parent: screen,
|
|
363
|
+
label: ' Sessions ',
|
|
364
|
+
top: 6,
|
|
365
|
+
left: 0,
|
|
366
|
+
right: 0,
|
|
367
|
+
height: '40%',
|
|
368
|
+
border: 'line',
|
|
369
|
+
mouse: true,
|
|
370
|
+
keys: true,
|
|
371
|
+
vi: false,
|
|
372
|
+
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'cyan' } },
|
|
373
|
+
style: {
|
|
374
|
+
border: { fg: 'cyan' },
|
|
375
|
+
selected: { fg: 'black', bg: 'cyan', bold: true },
|
|
376
|
+
item: { fg: 'white' },
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const detailBox = blessed.log({
|
|
381
|
+
parent: screen,
|
|
382
|
+
label: ' Details ',
|
|
383
|
+
top: '50%',
|
|
384
|
+
left: 0,
|
|
385
|
+
right: 0,
|
|
386
|
+
bottom: 3,
|
|
387
|
+
border: 'line',
|
|
388
|
+
padding: { left: 1, right: 1 },
|
|
389
|
+
scrollable: true,
|
|
390
|
+
mouse: true,
|
|
391
|
+
keys: true,
|
|
392
|
+
vi: true,
|
|
393
|
+
alwaysScroll: true,
|
|
394
|
+
tags: false,
|
|
395
|
+
parseTags: false,
|
|
396
|
+
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'cyan' } },
|
|
397
|
+
style: { border: { fg: 'cyan' }, fg: 'white' },
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const status = blessed.box({
|
|
401
|
+
parent: screen,
|
|
402
|
+
left: 0,
|
|
403
|
+
right: 0,
|
|
404
|
+
bottom: 0,
|
|
405
|
+
height: 3,
|
|
406
|
+
padding: { left: 1, right: 1 },
|
|
407
|
+
style: { fg: 'white', bg: 'black' },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const prompt = blessed.prompt({
|
|
411
|
+
parent: screen,
|
|
412
|
+
border: 'line',
|
|
413
|
+
height: 8,
|
|
414
|
+
width: '70%',
|
|
415
|
+
top: 'center',
|
|
416
|
+
left: 'center',
|
|
417
|
+
padding: { left: 1, right: 1 },
|
|
418
|
+
style: { border: { fg: 'yellow' }, fg: 'white', bg: 'black' },
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const question = blessed.question({
|
|
422
|
+
parent: screen,
|
|
423
|
+
border: 'line',
|
|
424
|
+
height: 6,
|
|
425
|
+
width: '70%',
|
|
426
|
+
top: 'center',
|
|
427
|
+
left: 'center',
|
|
428
|
+
padding: { left: 1, right: 1 },
|
|
429
|
+
style: { border: { fg: 'red' }, fg: 'white', bg: 'black' },
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const currentSessions = () => {
|
|
433
|
+
const group = groups[groupIndex];
|
|
434
|
+
return group === 'All' ? sessions : sessions.filter((s) => s.cwd === group);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const selectedSession = () => currentSessions()[selected] || null;
|
|
438
|
+
|
|
439
|
+
const groupLabel = (group) => {
|
|
440
|
+
if (group === 'All') return `All (${sessions.length})`;
|
|
441
|
+
const count = sessions.filter((s) => s.cwd === group).length;
|
|
442
|
+
return `${path.basename(group) || group} (${count})`;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const tagText = (text) => String(text).replace(/[{}]/g, '');
|
|
446
|
+
|
|
447
|
+
const groupsContent = () => {
|
|
448
|
+
if (!groups.length) return '';
|
|
449
|
+
const width = Math.max(20, (screen.width || 80) - 4);
|
|
450
|
+
const labels = groups.map((group, index) => {
|
|
451
|
+
const max = index === groupIndex ? 34 : 24;
|
|
452
|
+
return tagText(truncate(groupLabel(group), max));
|
|
453
|
+
});
|
|
454
|
+
const chipWidth = (index) => labels[index].length + 2;
|
|
455
|
+
let start = groupIndex;
|
|
456
|
+
let end = groupIndex;
|
|
457
|
+
let used = chipWidth(groupIndex);
|
|
458
|
+
|
|
459
|
+
while (start > 0 || end < groups.length - 1) {
|
|
460
|
+
const reserve = (start > 0 ? 4 : 0) + (end < groups.length - 1 ? 4 : 0);
|
|
461
|
+
const leftCost = start > 0 ? chipWidth(start - 1) + 2 : Infinity;
|
|
462
|
+
const rightCost = end < groups.length - 1 ? chipWidth(end + 1) + 2 : Infinity;
|
|
463
|
+
const preferLeft = groupIndex - start <= end - groupIndex;
|
|
464
|
+
const firstCost = preferLeft ? leftCost : rightCost;
|
|
465
|
+
const secondCost = preferLeft ? rightCost : leftCost;
|
|
466
|
+
|
|
467
|
+
if (used + firstCost + reserve <= width) {
|
|
468
|
+
if (preferLeft) start -= 1;
|
|
469
|
+
else end += 1;
|
|
470
|
+
used += firstCost;
|
|
471
|
+
} else if (used + secondCost + reserve <= width) {
|
|
472
|
+
if (preferLeft) end += 1;
|
|
473
|
+
else start -= 1;
|
|
474
|
+
used += secondCost;
|
|
475
|
+
} else {
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const parts = [];
|
|
481
|
+
if (start > 0) parts.push('...');
|
|
482
|
+
for (let index = start; index <= end; index += 1) {
|
|
483
|
+
const label = ` ${labels[index]} `;
|
|
484
|
+
parts.push(index === groupIndex ? `{black-fg}{cyan-bg}{bold}${label}{/bold}{/cyan-bg}{/black-fg}` : label);
|
|
485
|
+
}
|
|
486
|
+
if (end < groups.length - 1) parts.push('...');
|
|
487
|
+
return parts.join(' ');
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const sessionLabel = (session) => {
|
|
491
|
+
const flags = [
|
|
492
|
+
session.name ? 'renamed' : '',
|
|
493
|
+
session.note ? 'note' : '',
|
|
494
|
+
].filter(Boolean).join(',');
|
|
495
|
+
const title = session.name || session.first || session.last || '(no prompt)';
|
|
496
|
+
const flagText = flags ? `[${flags}]` : '';
|
|
497
|
+
return `${shortId(session.id)} ${String(session.turns).padStart(2)}t ${truncate(localTime(session.updatedAt), 18)} ${flagText} ${truncate(title, 90)}`;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const detailContent = (session) => {
|
|
501
|
+
if (!session) return 'No sessions in this project.';
|
|
502
|
+
const title = session.name || session.first || session.last || '(no prompt)';
|
|
503
|
+
return [
|
|
504
|
+
title,
|
|
505
|
+
'',
|
|
506
|
+
`id: ${session.id}`,
|
|
507
|
+
`cwd: ${session.cwd}`,
|
|
508
|
+
`started: ${localTime(session.startedAt)}`,
|
|
509
|
+
`updated: ${localTime(session.updatedAt)}`,
|
|
510
|
+
`turns: ${session.turns}`,
|
|
511
|
+
session.note ? `note: ${session.note}` : '',
|
|
512
|
+
'',
|
|
513
|
+
`last user: ${session.last || session.first || ''}`,
|
|
514
|
+
'',
|
|
515
|
+
`last assistant: ${session.lastAssistant || ''}`,
|
|
516
|
+
].filter((line) => line !== '').join('\n');
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const setMessage = (text, isError = false) => {
|
|
520
|
+
message = text || 'Ready';
|
|
521
|
+
status.style.fg = isError ? 'red' : 'white';
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const promptOpen = () => prompt.visible || question.visible;
|
|
525
|
+
|
|
526
|
+
const reload = () => {
|
|
527
|
+
sessions = listSessions().filter((s) => !s.archived);
|
|
528
|
+
groups = ['All', ...new Set(sessions.map((s) => s.cwd))];
|
|
529
|
+
if (groupIndex >= groups.length) groupIndex = Math.max(0, groups.length - 1);
|
|
530
|
+
const visible = currentSessions();
|
|
531
|
+
if (selected >= visible.length) selected = Math.max(0, visible.length - 1);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const syncList = () => {
|
|
535
|
+
const visible = currentSessions();
|
|
536
|
+
const listRows = Math.max(1, (screen.height || 24) - 11);
|
|
537
|
+
const items = visible.length ? visible.map(sessionLabel) : ['No sessions in this project.'];
|
|
538
|
+
while (items.length < listRows) items.push('');
|
|
539
|
+
syncingList = true;
|
|
540
|
+
sessionsList.clearItems();
|
|
541
|
+
sessionsList.setItems(items);
|
|
542
|
+
selected = Math.min(selected, Math.max(0, visible.length - 1));
|
|
543
|
+
sessionsList.childBase = 0;
|
|
544
|
+
sessionsList.childOffset = 0;
|
|
545
|
+
sessionsList.select(selected);
|
|
546
|
+
sessionsList.scrollTo(0);
|
|
547
|
+
syncingList = false;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const render = () => {
|
|
551
|
+
const visible = currentSessions();
|
|
552
|
+
header.setContent(` Codex Workbench\n ${visible.length}/${sessions.length} visible ${groups[groupIndex] === 'All' ? 'All projects' : groups[groupIndex]}`);
|
|
553
|
+
groupsBar.setContent(groupsContent());
|
|
554
|
+
detailBox.setLabel(' Details ');
|
|
555
|
+
detailBox.setContent(detailContent(selectedSession()));
|
|
556
|
+
status.setContent(`${message || 'Ready'}\nEnter/r resume tab focus f fork v view n rename o note a archive d delete q quit`);
|
|
557
|
+
screen.render();
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const askInput = (label, initial = '') => new Promise((resolve) => {
|
|
561
|
+
prompt.input(label, initial, (err, value) => resolve(err ? null : value));
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const askConfirm = (label) => new Promise((resolve) => {
|
|
565
|
+
question.ask(label, (err, answer) => resolve(!err && Boolean(answer)));
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const leaveScreen = () => {
|
|
569
|
+
screen.destroy();
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const refreshAfterAction = (text, isError = false) => {
|
|
573
|
+
setMessage(text, isError);
|
|
574
|
+
reload();
|
|
575
|
+
syncList();
|
|
576
|
+
render();
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const switchGroup = (offset) => {
|
|
580
|
+
if (!groups.length) return;
|
|
581
|
+
groupIndex = Math.max(0, Math.min(groups.length - 1, groupIndex + offset));
|
|
582
|
+
selected = 0;
|
|
583
|
+
syncList();
|
|
584
|
+
render();
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const runCodexAndReturn = (command, session, args = [], doneText = `${command} finished.`) => {
|
|
588
|
+
screen.leave();
|
|
589
|
+
let status = 0;
|
|
590
|
+
try {
|
|
591
|
+
status = codexCommand(command, session, args);
|
|
592
|
+
} finally {
|
|
593
|
+
screen.enter();
|
|
594
|
+
}
|
|
595
|
+
if (status === 0) refreshAfterAction(doneText);
|
|
596
|
+
else refreshAfterAction(`${command} exited with code ${status}.`, true);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const runAction = async (action) => {
|
|
600
|
+
if (promptOpen()) return;
|
|
601
|
+
const session = selectedSession();
|
|
602
|
+
if (!session) return;
|
|
603
|
+
try {
|
|
604
|
+
await action(session);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
setMessage(`error: ${err.message}`, true);
|
|
607
|
+
render();
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
reload();
|
|
612
|
+
setMessage('Ready');
|
|
613
|
+
syncList();
|
|
614
|
+
|
|
615
|
+
sessionsList.on('select item', (_item, index) => {
|
|
616
|
+
if (syncingList) return;
|
|
617
|
+
const visible = currentSessions();
|
|
618
|
+
if (index >= visible.length) {
|
|
619
|
+
selected = Math.max(0, visible.length - 1);
|
|
620
|
+
sessionsList.select(selected);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
selected = Math.min(index, Math.max(0, visible.length - 1));
|
|
624
|
+
detailBox.setContent(detailContent(selectedSession()));
|
|
625
|
+
screen.render();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
sessionsList.on('select', () => runAction((session) => {
|
|
629
|
+
runCodexAndReturn('resume', session);
|
|
630
|
+
}));
|
|
631
|
+
|
|
632
|
+
sessionsList.key(['j'], () => {
|
|
633
|
+
if (promptOpen()) return;
|
|
634
|
+
sessionsList.down();
|
|
635
|
+
screen.render();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
sessionsList.key(['k'], () => {
|
|
639
|
+
if (promptOpen()) return;
|
|
640
|
+
sessionsList.up();
|
|
641
|
+
screen.render();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
sessionsList.key(['left', 'h'], () => {
|
|
645
|
+
if (promptOpen()) return;
|
|
646
|
+
switchGroup(-1);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
sessionsList.key(['right', 'l'], () => {
|
|
650
|
+
if (promptOpen()) return;
|
|
651
|
+
switchGroup(1);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
detailBox.key(['left', 'h'], () => {
|
|
655
|
+
if (promptOpen()) return;
|
|
656
|
+
switchGroup(-1);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
detailBox.key(['right', 'l'], () => {
|
|
660
|
+
if (promptOpen()) return;
|
|
661
|
+
switchGroup(1);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
screen.key(['tab'], () => {
|
|
665
|
+
if (promptOpen()) return;
|
|
666
|
+
if (screen.focused === detailBox) sessionsList.focus();
|
|
667
|
+
else detailBox.focus();
|
|
668
|
+
screen.render();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
screen.key(['q', 'escape', 'C-c'], () => {
|
|
672
|
+
if (promptOpen()) return;
|
|
673
|
+
leaveScreen();
|
|
674
|
+
process.exit(0);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
screen.key(['r'], () => runAction((session) => {
|
|
678
|
+
runCodexAndReturn('resume', session);
|
|
679
|
+
}));
|
|
680
|
+
|
|
681
|
+
screen.key(['f'], () => runAction((session) => {
|
|
682
|
+
runCodexAndReturn('fork', session);
|
|
683
|
+
}));
|
|
684
|
+
|
|
685
|
+
screen.key(['v'], () => runAction((session) => {
|
|
686
|
+
leaveScreen();
|
|
687
|
+
printShow(session);
|
|
688
|
+
process.exit(0);
|
|
689
|
+
}));
|
|
690
|
+
|
|
691
|
+
screen.key(['n'], () => runAction(async (session) => {
|
|
692
|
+
const name = await askInput('Name', session.name || '');
|
|
693
|
+
if (name === null) return render();
|
|
694
|
+
updateMetadata(session, { name });
|
|
695
|
+
refreshAfterAction('Renamed.');
|
|
696
|
+
}));
|
|
697
|
+
|
|
698
|
+
screen.key(['o'], () => runAction(async (session) => {
|
|
699
|
+
const note = await askInput('Note', session.note || '');
|
|
700
|
+
if (note === null) return render();
|
|
701
|
+
updateMetadata(session, { note });
|
|
702
|
+
refreshAfterAction('Note saved.');
|
|
703
|
+
}));
|
|
704
|
+
|
|
705
|
+
screen.key(['a'], () => runAction((session) => {
|
|
706
|
+
runCodexAndReturn('archive', session, [], `Archived ${shortId(session.id)}.`);
|
|
707
|
+
}));
|
|
708
|
+
|
|
709
|
+
screen.key(['d'], () => runAction(async (session) => {
|
|
710
|
+
const confirmed = await askConfirm(`Delete ${shortId(session.id)}? Enter/y to confirm, n/Esc to cancel`);
|
|
711
|
+
if (!confirmed) {
|
|
712
|
+
setMessage('Delete cancelled.');
|
|
713
|
+
return render();
|
|
714
|
+
}
|
|
715
|
+
runCodexAndReturn('delete', session, ['--force'], `Deleted ${shortId(session.id)}.`);
|
|
716
|
+
}));
|
|
717
|
+
|
|
718
|
+
sessionsList.focus();
|
|
719
|
+
render();
|
|
720
|
+
|
|
721
|
+
return new Promise(() => {});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function main() {
|
|
725
|
+
const [cmd = 'ui', ...rest] = process.argv.slice(2);
|
|
726
|
+
if (cmd === '-h' || cmd === '--help' || cmd === 'help') return usage();
|
|
727
|
+
|
|
728
|
+
const flags = parseFlags(rest);
|
|
729
|
+
if (cmd === 'doctor') return printDoctor();
|
|
730
|
+
|
|
731
|
+
const sessions = listSessions();
|
|
732
|
+
|
|
733
|
+
if (cmd === 'ui') return ui();
|
|
734
|
+
if (cmd === 'list' || cmd === 'ls') return printList(sessions, flags);
|
|
735
|
+
if (cmd === 'show') return printShow(resolveSession(flags._[0], sessions));
|
|
736
|
+
if (cmd === 'rename') return updateMetadata(resolveSession(flags._[0], sessions), { name: flags._.slice(1).join(' ') });
|
|
737
|
+
if (cmd === 'note') return updateMetadata(resolveSession(flags._[0], sessions), { note: flags._.slice(1).join(' ') });
|
|
738
|
+
if (cmd === 'resume') return codexCommand('resume', resolveSession(flags._[0], sessions), flags._.slice(1), true);
|
|
739
|
+
if (cmd === 'fork') return codexCommand('fork', resolveSession(flags._[0], sessions), [], true);
|
|
740
|
+
if (cmd === 'archive') return codexCommand('archive', resolveSession(flags._[0], sessions));
|
|
741
|
+
if (cmd === 'unarchive') return codexCommand('unarchive', resolveSession(flags._[0], sessions));
|
|
742
|
+
if (cmd === 'delete') return codexCommand('delete', resolveSession(flags._[0], sessions), flags.force ? ['--force'] : []);
|
|
743
|
+
|
|
744
|
+
usage();
|
|
745
|
+
process.exitCode = 2;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
main().catch((err) => {
|
|
749
|
+
console.error(`error: ${err.message}`);
|
|
750
|
+
process.exit(1);
|
|
751
|
+
});
|
package/src/codex-bin.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CODEX_BIN = '/Applications/Codex.app/Contents/Resources/codex';
|
|
8
|
+
|
|
9
|
+
function isExecutable(file) {
|
|
10
|
+
try {
|
|
11
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findOnPath(command, pathValue = process.env.PATH || '') {
|
|
19
|
+
for (const dir of pathValue.split(path.delimiter)) {
|
|
20
|
+
if (!dir) continue;
|
|
21
|
+
const candidate = path.join(dir, command);
|
|
22
|
+
if (isExecutable(candidate)) return candidate;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function shellQuote(value) {
|
|
28
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function executableFromOutput(output) {
|
|
32
|
+
for (const line of String(output || '').split(/\r?\n/)) {
|
|
33
|
+
const candidate = line.trim();
|
|
34
|
+
if (candidate && path.isAbsolute(candidate) && isExecutable(candidate)) return candidate;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runShellLookup(shell, shellArgs, command, env) {
|
|
40
|
+
const result = spawnSync(shell, [...shellArgs, `command -v ${shellQuote(command)}`], {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
env,
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
});
|
|
45
|
+
if (result.error || result.status !== 0) return null;
|
|
46
|
+
return executableFromOutput(result.stdout);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findWithShell(command, env = process.env) {
|
|
50
|
+
const shell = env.SHELL || '/bin/sh';
|
|
51
|
+
if (!isExecutable(shell)) return null;
|
|
52
|
+
|
|
53
|
+
return runShellLookup(shell, ['-lc'], command, env) ||
|
|
54
|
+
runShellLookup(shell, ['-ic'], command, env);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inspectCodexBin(options = {}) {
|
|
58
|
+
const env = options.env || process.env;
|
|
59
|
+
const fallbackPath = Object.prototype.hasOwnProperty.call(options, 'fallbackPath')
|
|
60
|
+
? options.fallbackPath
|
|
61
|
+
: DEFAULT_CODEX_BIN;
|
|
62
|
+
const shell = env.SHELL || '/bin/sh';
|
|
63
|
+
const checks = [];
|
|
64
|
+
|
|
65
|
+
if (env.CODEX_BIN) {
|
|
66
|
+
const executable = isExecutable(env.CODEX_BIN);
|
|
67
|
+
checks.push({ source: 'CODEX_BIN', path: env.CODEX_BIN, executable });
|
|
68
|
+
return {
|
|
69
|
+
ok: executable,
|
|
70
|
+
path: executable ? env.CODEX_BIN : null,
|
|
71
|
+
source: executable ? 'CODEX_BIN' : null,
|
|
72
|
+
checks,
|
|
73
|
+
error: executable ? null : `CODEX_BIN is not executable: ${env.CODEX_BIN}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
checks.push({ source: 'shell', shell, mode: 'login', executable: isExecutable(shell) });
|
|
78
|
+
const fromLoginShell = isExecutable(shell) ? runShellLookup(shell, ['-lc'], 'codex', env) : null;
|
|
79
|
+
if (fromLoginShell) {
|
|
80
|
+
checks[checks.length - 1].path = fromLoginShell;
|
|
81
|
+
return { ok: true, path: fromLoginShell, source: 'shell login PATH', checks, error: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
checks.push({ source: 'shell', shell, mode: 'interactive', executable: isExecutable(shell) });
|
|
85
|
+
const fromInteractiveShell = isExecutable(shell) ? runShellLookup(shell, ['-ic'], 'codex', env) : null;
|
|
86
|
+
if (fromInteractiveShell) {
|
|
87
|
+
checks[checks.length - 1].path = fromInteractiveShell;
|
|
88
|
+
return { ok: true, path: fromInteractiveShell, source: 'shell interactive PATH', checks, error: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fromProcessPath = findOnPath('codex', env.PATH || '');
|
|
92
|
+
checks.push({ source: 'process PATH', path: fromProcessPath, executable: Boolean(fromProcessPath) });
|
|
93
|
+
if (fromProcessPath) {
|
|
94
|
+
return { ok: true, path: fromProcessPath, source: 'process PATH', checks, error: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fallbackExecutable = Boolean(fallbackPath && isExecutable(fallbackPath));
|
|
98
|
+
checks.push({ source: 'fallback', path: fallbackPath || '', executable: fallbackExecutable });
|
|
99
|
+
if (fallbackExecutable) {
|
|
100
|
+
return { ok: true, path: fallbackPath, source: 'fallback', checks, error: null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
path: null,
|
|
106
|
+
source: null,
|
|
107
|
+
checks,
|
|
108
|
+
error: 'Could not find the codex executable. Set CODEX_BIN or add codex to your shell PATH.',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveCodexBin(options = {}) {
|
|
113
|
+
const result = inspectCodexBin(options);
|
|
114
|
+
if (result.ok) return result.path;
|
|
115
|
+
throw new Error(result.error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
DEFAULT_CODEX_BIN,
|
|
120
|
+
findOnPath,
|
|
121
|
+
findWithShell,
|
|
122
|
+
inspectCodexBin,
|
|
123
|
+
isExecutable,
|
|
124
|
+
resolveCodexBin,
|
|
125
|
+
};
|