@fclef819/cdx 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 +54 -0
- package/bin/cdx.js +305 -0
- package/package.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fclef
|
|
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,54 @@
|
|
|
1
|
+
# cdx
|
|
2
|
+
|
|
3
|
+
Codex session wrapper CLI.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
- Keeping sessions separate by use case (development, testing, review) helps maintain accuracy.
|
|
8
|
+
- Managing multiple sessions manually is tedious; this tool streamlines it.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Codex CLI installed and available as `codex` in your PATH
|
|
13
|
+
|
|
14
|
+
## Notes
|
|
15
|
+
|
|
16
|
+
- This is an unofficial community tool and is not affiliated with OpenAI.
|
|
17
|
+
- This tool does not bundle or redistribute the Codex CLI.
|
|
18
|
+
- Scope: manage Codex session selection and launch/resume workflows only.
|
|
19
|
+
- OpenAI, Codex, and related marks are trademarks of their respective owners.
|
|
20
|
+
|
|
21
|
+
## .cdx format
|
|
22
|
+
|
|
23
|
+
Each line is:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
<uuid>\t<label>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
- `cdx` to select or create a session
|
|
32
|
+
- `cdx here` to use `.cdx` from the current directory without parent search
|
|
33
|
+
- `cdx rm` to remove a session from `.cdx`
|
|
34
|
+
- `cdx rm here` or `cdx here rm` to remove a session from `.cdx` in the current directory
|
|
35
|
+
- `cdx -h`, `cdx --help`, or `cdx help` to show help
|
|
36
|
+
|
|
37
|
+
## Install (npm)
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
npm install -g @fclef819/cdx
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install (local/dev)
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
npm install
|
|
47
|
+
npm link
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Issues & Feedback
|
|
51
|
+
|
|
52
|
+
If you find a bug or want an enhancement, please open an issue in:
|
|
53
|
+
https://github.com/fclef819/-fclef-cdx
|
|
54
|
+
Repro steps and environment details are appreciated.
|
package/bin/cdx.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { spawnSync } = require("child_process");
|
|
7
|
+
const prompts = require("prompts");
|
|
8
|
+
|
|
9
|
+
const CDX_FILENAME = ".cdx";
|
|
10
|
+
const CODEX_HOME = path.join(process.env.HOME || "", ".codex");
|
|
11
|
+
const HISTORY_PATH = path.join(CODEX_HOME, "history.jsonl");
|
|
12
|
+
const SESSIONS_DIR = path.join(CODEX_HOME, "sessions");
|
|
13
|
+
|
|
14
|
+
function findCdxFile(startDir) {
|
|
15
|
+
let dir = path.resolve(startDir);
|
|
16
|
+
while (true) {
|
|
17
|
+
const candidate = path.join(dir, CDX_FILENAME);
|
|
18
|
+
if (fs.existsSync(candidate)) {
|
|
19
|
+
return { dir, filePath: candidate };
|
|
20
|
+
}
|
|
21
|
+
const parent = path.dirname(dir);
|
|
22
|
+
if (parent === dir) return null;
|
|
23
|
+
dir = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadEntries(filePath) {
|
|
28
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
29
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
30
|
+
return content
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((line) => {
|
|
35
|
+
const tabIndex = line.indexOf("\t");
|
|
36
|
+
if (tabIndex === -1) return null;
|
|
37
|
+
const uuid = line.slice(0, tabIndex).trim();
|
|
38
|
+
const label = line.slice(tabIndex + 1).trim();
|
|
39
|
+
if (!uuid || !label) return null;
|
|
40
|
+
return { uuid, label };
|
|
41
|
+
})
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeLabel(label) {
|
|
46
|
+
return label.replace(/[\t\n\r]+/g, " ").trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendEntry(filePath, entry) {
|
|
50
|
+
const line = `${entry.uuid}\t${entry.label}\n`;
|
|
51
|
+
fs.appendFileSync(filePath, line, "utf8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeEntries(filePath, entries) {
|
|
55
|
+
if (!entries.length) {
|
|
56
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const content = entries.map((e) => `${e.uuid}\t${e.label}`).join("\n") + "\n";
|
|
60
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function collectSessionFiles(dir, results) {
|
|
64
|
+
if (!fs.existsSync(dir)) return;
|
|
65
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const fullPath = path.join(dir, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
collectSessionFiles(fullPath, results);
|
|
70
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
71
|
+
const stat = fs.statSync(fullPath);
|
|
72
|
+
results.push({ path: fullPath, mtimeMs: stat.mtimeMs });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractIdFromFilename(filePath) {
|
|
78
|
+
const match = filePath.match(
|
|
79
|
+
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
|
|
80
|
+
);
|
|
81
|
+
return match ? match[1] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readSessionIdFromFile(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
87
|
+
const firstLine = content.split("\n")[0];
|
|
88
|
+
const record = JSON.parse(firstLine);
|
|
89
|
+
if (record && record.type === "session_meta" && record.payload?.id) {
|
|
90
|
+
return record.payload.id;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore malformed file
|
|
94
|
+
}
|
|
95
|
+
return extractIdFromFilename(filePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getLatestSessionSnapshot() {
|
|
99
|
+
const files = [];
|
|
100
|
+
collectSessionFiles(SESSIONS_DIR, files);
|
|
101
|
+
if (!files.length) return null;
|
|
102
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
103
|
+
const latest = files[files.length - 1];
|
|
104
|
+
const id = readSessionIdFromFile(latest.path);
|
|
105
|
+
return { id, mtimeMs: latest.mtimeMs, path: latest.path };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getLastHistorySessionId() {
|
|
109
|
+
if (!fs.existsSync(HISTORY_PATH)) return null;
|
|
110
|
+
const lines = fs.readFileSync(HISTORY_PATH, "utf8").trim().split("\n");
|
|
111
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
112
|
+
try {
|
|
113
|
+
const record = JSON.parse(lines[i]);
|
|
114
|
+
if (record && record.session_id) return record.session_id;
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore malformed lines
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getNewestHistorySessionIdSince(previousId) {
|
|
123
|
+
if (!fs.existsSync(HISTORY_PATH)) return null;
|
|
124
|
+
const lines = fs.readFileSync(HISTORY_PATH, "utf8").trim().split("\n");
|
|
125
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
126
|
+
try {
|
|
127
|
+
const record = JSON.parse(lines[i]);
|
|
128
|
+
if (record && record.session_id && record.session_id !== previousId) {
|
|
129
|
+
return record.session_id;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore malformed lines
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function runCodex(args, cwd) {
|
|
139
|
+
const check = spawnSync("codex", ["--version"], { stdio: "ignore" });
|
|
140
|
+
if (check.error && check.error.code === "ENOENT") {
|
|
141
|
+
console.error(
|
|
142
|
+
"Codex CLI is not available. Please install it and ensure `codex` is on your PATH."
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
const result = spawnSync("codex", args, { stdio: "inherit", cwd });
|
|
147
|
+
if (result.error) {
|
|
148
|
+
console.error("Failed to run codex:", result.error.message);
|
|
149
|
+
process.exit(result.status ?? 1);
|
|
150
|
+
}
|
|
151
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function selectSession(entries) {
|
|
155
|
+
const choices = [
|
|
156
|
+
{ title: "new", value: { type: "new" } },
|
|
157
|
+
...entries.map((entry) => ({
|
|
158
|
+
title: `${entry.label} (${entry.uuid})`,
|
|
159
|
+
value: { type: "resume", entry }
|
|
160
|
+
}))
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const response = await prompts({
|
|
164
|
+
type: "select",
|
|
165
|
+
name: "selection",
|
|
166
|
+
message: "Select a session",
|
|
167
|
+
choices
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return response.selection;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function promptLabel() {
|
|
174
|
+
const response = await prompts({
|
|
175
|
+
type: "text",
|
|
176
|
+
name: "label",
|
|
177
|
+
message: "Label for new session",
|
|
178
|
+
validate: (value) => (value && value.trim() ? true : "Label is required")
|
|
179
|
+
});
|
|
180
|
+
return response.label;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runDefault(startDir, options) {
|
|
184
|
+
const found = options.here ? null : findCdxFile(startDir);
|
|
185
|
+
const workDir = found ? found.dir : startDir;
|
|
186
|
+
const cdxPath = found ? found.filePath : path.join(startDir, CDX_FILENAME);
|
|
187
|
+
const entries = loadEntries(found?.filePath);
|
|
188
|
+
|
|
189
|
+
console.log(`.cdx: ${cdxPath}`);
|
|
190
|
+
const selection = await selectSession(entries);
|
|
191
|
+
if (!selection) return;
|
|
192
|
+
|
|
193
|
+
if (selection.type === "new") {
|
|
194
|
+
const labelInput = await promptLabel();
|
|
195
|
+
if (!labelInput) return;
|
|
196
|
+
const label = sanitizeLabel(labelInput);
|
|
197
|
+
const previousHistoryId = getLastHistorySessionId();
|
|
198
|
+
const previousSession = getLatestSessionSnapshot();
|
|
199
|
+
runCodex([], workDir);
|
|
200
|
+
const latestSession = getLatestSessionSnapshot();
|
|
201
|
+
let newId = null;
|
|
202
|
+
if (
|
|
203
|
+
latestSession &&
|
|
204
|
+
latestSession.id &&
|
|
205
|
+
(!previousSession ||
|
|
206
|
+
latestSession.path !== previousSession.path ||
|
|
207
|
+
latestSession.mtimeMs > previousSession.mtimeMs)
|
|
208
|
+
) {
|
|
209
|
+
newId = latestSession.id;
|
|
210
|
+
} else if (previousHistoryId) {
|
|
211
|
+
newId = getNewestHistorySessionIdSince(previousHistoryId);
|
|
212
|
+
}
|
|
213
|
+
if (!newId) {
|
|
214
|
+
console.error("Could not determine new session UUID; not updating .cdx.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
appendEntry(cdxPath, { uuid: newId, label });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (selection.type === "resume") {
|
|
222
|
+
runCodex(["resume", selection.entry.uuid], workDir);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runRemove(startDir, options) {
|
|
227
|
+
const found = options.here ? null : findCdxFile(startDir);
|
|
228
|
+
const targetPath = found ? found.filePath : path.join(startDir, CDX_FILENAME);
|
|
229
|
+
const targetDir = found ? found.dir : startDir;
|
|
230
|
+
if (!found) {
|
|
231
|
+
if (!fs.existsSync(targetPath)) {
|
|
232
|
+
console.log("No .cdx file found.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const entries = loadEntries(targetPath);
|
|
237
|
+
if (!entries.length) {
|
|
238
|
+
console.log("No sessions to remove.");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.log(`.cdx: ${targetPath}`);
|
|
243
|
+
const response = await prompts({
|
|
244
|
+
type: "select",
|
|
245
|
+
name: "selection",
|
|
246
|
+
message: "Select a session to remove",
|
|
247
|
+
choices: entries.map((entry) => ({
|
|
248
|
+
title: `${entry.label} (${entry.uuid})`,
|
|
249
|
+
value: entry.uuid
|
|
250
|
+
}))
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!response.selection) return;
|
|
254
|
+
const remaining = entries.filter((entry) => entry.uuid !== response.selection);
|
|
255
|
+
writeEntries(targetPath, remaining);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function printHelp() {
|
|
259
|
+
console.log(`cdx - Codex session wrapper
|
|
260
|
+
|
|
261
|
+
Usage:
|
|
262
|
+
cdx
|
|
263
|
+
cdx here
|
|
264
|
+
cdx rm
|
|
265
|
+
cdx rm here
|
|
266
|
+
cdx here rm
|
|
267
|
+
|
|
268
|
+
Notes:
|
|
269
|
+
- "here" skips parent directory search and uses .cdx in the current directory
|
|
270
|
+
- the selected .cdx path is shown before session selection
|
|
271
|
+
`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function main() {
|
|
275
|
+
const args = process.argv.slice(2);
|
|
276
|
+
const subcommand = args[0];
|
|
277
|
+
const startDir = process.cwd();
|
|
278
|
+
const here = args.includes("here");
|
|
279
|
+
const wantsHelp =
|
|
280
|
+
args.includes("-h") ||
|
|
281
|
+
args.includes("--help") ||
|
|
282
|
+
args.includes("help");
|
|
283
|
+
|
|
284
|
+
if (wantsHelp) {
|
|
285
|
+
printHelp();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (subcommand === "rm" || (here && args.includes("rm"))) {
|
|
290
|
+
await runRemove(startDir, { here });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (subcommand === "here" || here) {
|
|
295
|
+
await runDefault(startDir, { here: true });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await runDefault(startDir, { here: false });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
main().catch((err) => {
|
|
303
|
+
console.error(err);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fclef819/cdx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codex session wrapper",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"codex",
|
|
7
|
+
"cli",
|
|
8
|
+
"tui"
|
|
9
|
+
],
|
|
10
|
+
"bin": {
|
|
11
|
+
"cdx": "bin/cdx.js"
|
|
12
|
+
},
|
|
13
|
+
"type": "commonjs",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"prompts": "^2.4.2"
|
|
24
|
+
}
|
|
25
|
+
}
|