@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/bin/cdx.js +305 -0
  4. 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
+ }