codex-map 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 +41 -0
- package/bin/cli.js +51 -0
- package/package.json +41 -0
- package/public/app.js +1371 -0
- package/public/index.html +101 -0
- package/public/logo.png +0 -0
- package/public/style.css +950 -0
- package/server.js +1702 -0
package/server.js
ADDED
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { randomUUID } = require('crypto');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
const matter = require('gray-matter');
|
|
10
|
+
const chokidar = require('chokidar');
|
|
11
|
+
const TOML = require('@iarna/toml');
|
|
12
|
+
|
|
13
|
+
const app = express();
|
|
14
|
+
app.use(express.json({ limit: '2mb' }));
|
|
15
|
+
|
|
16
|
+
const PORT = process.env.PORT || 3131;
|
|
17
|
+
const CODEX_DIR = path.resolve(os.homedir(), '.codex');
|
|
18
|
+
const PINNED_FILE = path.join(CODEX_DIR, 'codex-map-projects.json');
|
|
19
|
+
const SESSION_ROOT = path.join(CODEX_DIR, 'sessions');
|
|
20
|
+
const STATE_DB = path.join(CODEX_DIR, 'state_5.sqlite');
|
|
21
|
+
const PLUGINS_CACHE_DIR = path.join(CODEX_DIR, '.tmp', 'plugins');
|
|
22
|
+
const PLUGINS_ROOT = path.join(PLUGINS_CACHE_DIR, 'plugins');
|
|
23
|
+
const MARKETPLACE_PATH = path.join(PLUGINS_CACHE_DIR, '.agents', 'plugins', 'marketplace.json');
|
|
24
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
25
|
+
const TREE_SKIP_DIRS = new Set(['.git', 'cache', 'log', 'logs', '.tmp', 'tmp', 'sqlite', 'vendor_imports']);
|
|
26
|
+
|
|
27
|
+
const cache = new Map();
|
|
28
|
+
const sseClients = new Set();
|
|
29
|
+
|
|
30
|
+
function invalidateCache() {
|
|
31
|
+
cache.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getCacheKey(projectPath) {
|
|
35
|
+
return projectPath ? `project:${path.resolve(projectPath)}` : 'global';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function getCachedScan(projectPath) {
|
|
39
|
+
const key = getCacheKey(projectPath);
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const hit = cache.get(key);
|
|
42
|
+
if (hit && (now - hit.ts) < 5000) return hit.data;
|
|
43
|
+
|
|
44
|
+
const data = await buildScanResult(projectPath);
|
|
45
|
+
cache.set(key, { ts: now, data });
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fileExists(filePath) {
|
|
50
|
+
try {
|
|
51
|
+
fs.accessSync(filePath);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeReadText(filePath) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeReadJson(filePath) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeReadToml(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
return TOML.parse(fs.readFileSync(filePath, 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeText(filePath, content) {
|
|
83
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
84
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sqlString(value) {
|
|
88
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function querySqliteJson(dbPath, sql) {
|
|
92
|
+
try {
|
|
93
|
+
const output = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
|
|
94
|
+
return output ? JSON.parse(output) : [];
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function execSqlite(dbPath, sql) {
|
|
101
|
+
execFileSync('sqlite3', [dbPath, sql], { encoding: 'utf8' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function excerpt(text, len = 220) {
|
|
105
|
+
if (!text) return '';
|
|
106
|
+
const clean = text.replace(/^---[\s\S]*?---\n?/, '').trim();
|
|
107
|
+
return clean.length > len ? `${clean.slice(0, len)}…` : clean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function wordCount(text) {
|
|
111
|
+
return text ? text.trim().split(/\s+/).length : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureArray(value) {
|
|
115
|
+
return Array.isArray(value) ? value : value == null ? [] : [value];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveIfExists(basePath, ...parts) {
|
|
119
|
+
const target = path.join(basePath, ...parts);
|
|
120
|
+
return fileExists(target) ? target : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readAgentsMd(basePath) {
|
|
124
|
+
if (!basePath) return null;
|
|
125
|
+
const filePath = resolveIfExists(basePath, 'AGENTS.md');
|
|
126
|
+
if (!filePath) return null;
|
|
127
|
+
const raw = safeReadText(filePath);
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
return { path: filePath, raw, excerpt: excerpt(raw, 320) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildSkillMeta(frontmatter, fallbackName) {
|
|
133
|
+
const data = frontmatter || {};
|
|
134
|
+
const allowedToolsRaw = data['allowed-tools'] || data.allowedTools || '';
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
displayName: data.name || fallbackName,
|
|
138
|
+
description: data.description || null,
|
|
139
|
+
allowedTools: allowedToolsRaw
|
|
140
|
+
? String(allowedToolsRaw).split(',').map(item => item.trim()).filter(Boolean)
|
|
141
|
+
: [],
|
|
142
|
+
argumentHint: data['argument-hint'] || data.argumentHint || null,
|
|
143
|
+
userInvocable: data['user-invocable'] !== false,
|
|
144
|
+
agent: data.agent || null
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readSkillsDir(basePath) {
|
|
149
|
+
const skillsDir = path.join(basePath, 'skills');
|
|
150
|
+
if (!fileExists(skillsDir)) return [];
|
|
151
|
+
|
|
152
|
+
const entries = [];
|
|
153
|
+
try {
|
|
154
|
+
const items = fs.readdirSync(skillsDir).sort();
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
const itemPath = path.join(skillsDir, item);
|
|
157
|
+
const stat = fs.statSync(itemPath);
|
|
158
|
+
|
|
159
|
+
if (stat.isDirectory()) {
|
|
160
|
+
const skillFile = resolveIfExists(itemPath, 'SKILL.md') || resolveIfExists(itemPath, `${item}.md`);
|
|
161
|
+
if (!skillFile) continue;
|
|
162
|
+
|
|
163
|
+
const raw = safeReadText(skillFile);
|
|
164
|
+
if (!raw) continue;
|
|
165
|
+
|
|
166
|
+
const parsed = matter(raw);
|
|
167
|
+
entries.push({
|
|
168
|
+
name: item,
|
|
169
|
+
filename: path.basename(skillFile),
|
|
170
|
+
path: skillFile,
|
|
171
|
+
raw,
|
|
172
|
+
body: parsed.content || raw,
|
|
173
|
+
frontmatter: parsed.data || {},
|
|
174
|
+
meta: buildSkillMeta(parsed.data, item),
|
|
175
|
+
excerpt: excerpt(parsed.content || raw, 180),
|
|
176
|
+
hasArgs: raw.includes('$ARGUMENTS'),
|
|
177
|
+
isFolder: true,
|
|
178
|
+
wordCount: wordCount(raw)
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!item.endsWith('.md')) continue;
|
|
184
|
+
|
|
185
|
+
const raw = safeReadText(itemPath);
|
|
186
|
+
if (!raw) continue;
|
|
187
|
+
|
|
188
|
+
const parsed = matter(raw);
|
|
189
|
+
entries.push({
|
|
190
|
+
name: item.replace(/\.md$/, ''),
|
|
191
|
+
filename: item,
|
|
192
|
+
path: itemPath,
|
|
193
|
+
raw,
|
|
194
|
+
body: parsed.content || raw,
|
|
195
|
+
frontmatter: parsed.data || {},
|
|
196
|
+
meta: buildSkillMeta(parsed.data, item.replace(/\.md$/, '')),
|
|
197
|
+
excerpt: excerpt(parsed.content || raw, 180),
|
|
198
|
+
hasArgs: raw.includes('$ARGUMENTS'),
|
|
199
|
+
isFolder: false,
|
|
200
|
+
wordCount: wordCount(raw)
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return entries;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getSkillsBaseDir(scope, projectPath) {
|
|
211
|
+
if (scope === 'project') {
|
|
212
|
+
if (!projectPath) throw new Error('Missing projectPath for project-scoped skill operation');
|
|
213
|
+
return path.join(path.resolve(projectPath), '.codex', 'skills');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return path.join(CODEX_DIR, 'skills');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sanitizeSkillName(name) {
|
|
220
|
+
return String(name || '').trim().replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveSkillFile(baseDir, name) {
|
|
224
|
+
const safeName = sanitizeSkillName(name);
|
|
225
|
+
const directFile = path.join(baseDir, `${safeName}.md`);
|
|
226
|
+
const folderSkill = path.join(baseDir, safeName, 'SKILL.md');
|
|
227
|
+
const folderAlt = path.join(baseDir, safeName, `${safeName}.md`);
|
|
228
|
+
|
|
229
|
+
if (fileExists(directFile)) return directFile;
|
|
230
|
+
if (fileExists(folderSkill)) return folderSkill;
|
|
231
|
+
if (fileExists(folderAlt)) return folderAlt;
|
|
232
|
+
return directFile;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readConfigToml() {
|
|
236
|
+
const filePath = path.join(CODEX_DIR, 'config.toml');
|
|
237
|
+
const raw = safeReadText(filePath);
|
|
238
|
+
const data = raw ? safeReadToml(filePath) : null;
|
|
239
|
+
if (!raw || !data) return null;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
path: filePath,
|
|
243
|
+
raw,
|
|
244
|
+
excerpt: excerpt(raw, 420),
|
|
245
|
+
approvalsReviewer: data.approvals_reviewer || null,
|
|
246
|
+
personality: data.personality || null,
|
|
247
|
+
projects: Object.entries(data.projects || {}).map(([projectPath, cfg]) => ({
|
|
248
|
+
path: projectPath,
|
|
249
|
+
trustLevel: cfg.trust_level || null
|
|
250
|
+
})),
|
|
251
|
+
mcpServers: Object.entries(data.mcp_servers || {}).map(([name, cfg]) => ({
|
|
252
|
+
name,
|
|
253
|
+
command: cfg.command || null,
|
|
254
|
+
args: ensureArray(cfg.args),
|
|
255
|
+
envKeys: Object.keys(cfg.env || {}),
|
|
256
|
+
cwd: cfg.cwd || null
|
|
257
|
+
})),
|
|
258
|
+
profiles: Object.entries(data.profiles || {}).map(([name, cfg]) => ({
|
|
259
|
+
name,
|
|
260
|
+
keys: Object.keys(cfg || {})
|
|
261
|
+
})),
|
|
262
|
+
featureFlags: Object.keys(data.features || {}).filter(key => data.features[key])
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function readConfigTomlDoc() {
|
|
267
|
+
const filePath = path.join(CODEX_DIR, 'config.toml');
|
|
268
|
+
const raw = safeReadText(filePath) || '';
|
|
269
|
+
const data = safeReadToml(filePath) || {};
|
|
270
|
+
return { filePath, raw, data };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function writeConfigTomlData(data) {
|
|
274
|
+
const filePath = path.join(CODEX_DIR, 'config.toml');
|
|
275
|
+
const raw = TOML.stringify(data);
|
|
276
|
+
writeText(filePath, raw);
|
|
277
|
+
invalidateCache();
|
|
278
|
+
return summarizeConfigToml(raw, data, filePath);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sessionFilePathFor(id, date = new Date()) {
|
|
282
|
+
const year = String(date.getFullYear());
|
|
283
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
284
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
285
|
+
const stamp = date.toISOString().replace(/\.\d{3}Z$/, '').replace(/:/g, '-');
|
|
286
|
+
return path.join(SESSION_ROOT, year, month, day, `rollout-${stamp}-${id}.jsonl`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function createSessionFile({ id, cwd, title, modelProvider = 'openai', cliVersion = '0.120.0' }) {
|
|
290
|
+
const now = new Date();
|
|
291
|
+
const iso = now.toISOString();
|
|
292
|
+
const filePath = sessionFilePathFor(id, now);
|
|
293
|
+
const rows = [
|
|
294
|
+
{
|
|
295
|
+
timestamp: iso,
|
|
296
|
+
type: 'session_meta',
|
|
297
|
+
payload: {
|
|
298
|
+
id,
|
|
299
|
+
timestamp: iso,
|
|
300
|
+
cwd,
|
|
301
|
+
originator: 'codex-map',
|
|
302
|
+
cli_version: cliVersion,
|
|
303
|
+
source: 'cli',
|
|
304
|
+
model_provider: modelProvider
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
timestamp: iso,
|
|
309
|
+
type: 'event_msg',
|
|
310
|
+
payload: {
|
|
311
|
+
type: 'user_message',
|
|
312
|
+
message: title
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
writeText(filePath, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`);
|
|
318
|
+
return { filePath, timestamp: Math.floor(now.getTime() / 1000) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function summarizeConfigToml(raw, data, filePath) {
|
|
322
|
+
return {
|
|
323
|
+
path: filePath,
|
|
324
|
+
raw,
|
|
325
|
+
excerpt: excerpt(raw, 420),
|
|
326
|
+
approvalsReviewer: data.approvals_reviewer || null,
|
|
327
|
+
personality: data.personality || null,
|
|
328
|
+
projects: Object.entries(data.projects || {}).map(([projectPath, cfg]) => ({
|
|
329
|
+
path: projectPath,
|
|
330
|
+
trustLevel: cfg.trust_level || null
|
|
331
|
+
})),
|
|
332
|
+
mcpServers: Object.entries(data.mcp_servers || {}).map(([name, cfg]) => ({
|
|
333
|
+
name,
|
|
334
|
+
command: cfg.command || null,
|
|
335
|
+
args: ensureArray(cfg.args),
|
|
336
|
+
envKeys: Object.keys(cfg.env || {}),
|
|
337
|
+
env: cfg.env || {},
|
|
338
|
+
cwd: cfg.cwd || null
|
|
339
|
+
})),
|
|
340
|
+
profiles: Object.entries(data.profiles || {}).map(([name, cfg]) => ({
|
|
341
|
+
name,
|
|
342
|
+
keys: Object.keys(cfg || {})
|
|
343
|
+
})),
|
|
344
|
+
featureFlags: Object.keys(data.features || {}).filter(key => data.features[key])
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function readMcpJson(projectPath) {
|
|
349
|
+
if (!projectPath) return null;
|
|
350
|
+
|
|
351
|
+
const candidates = [
|
|
352
|
+
path.join(projectPath, '.mcp.json'),
|
|
353
|
+
path.join(projectPath, '.codex', '.mcp.json')
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
for (const candidate of candidates) {
|
|
357
|
+
const data = safeReadJson(candidate);
|
|
358
|
+
if (!data) continue;
|
|
359
|
+
return {
|
|
360
|
+
path: candidate,
|
|
361
|
+
raw: JSON.stringify(data, null, 2),
|
|
362
|
+
servers: Object.keys(data.mcpServers || {}),
|
|
363
|
+
data
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function readPluginManifests() {
|
|
371
|
+
if (!fileExists(PLUGINS_ROOT)) return [];
|
|
372
|
+
|
|
373
|
+
const marketplace = safeReadJson(MARKETPLACE_PATH) || { plugins: [] };
|
|
374
|
+
const marketplaceMap = new Map((marketplace.plugins || []).map(item => [item.name, item]));
|
|
375
|
+
|
|
376
|
+
const plugins = [];
|
|
377
|
+
try {
|
|
378
|
+
for (const item of fs.readdirSync(PLUGINS_ROOT).sort()) {
|
|
379
|
+
const manifestPath = path.join(PLUGINS_ROOT, item, '.codex-plugin', 'plugin.json');
|
|
380
|
+
const manifest = safeReadJson(manifestPath);
|
|
381
|
+
if (!manifest) continue;
|
|
382
|
+
const market = marketplaceMap.get(item) || {};
|
|
383
|
+
|
|
384
|
+
plugins.push({
|
|
385
|
+
id: manifest.id || item,
|
|
386
|
+
name: manifest.name || manifest.id || item,
|
|
387
|
+
description: manifest.description || null,
|
|
388
|
+
version: manifest.version || null,
|
|
389
|
+
category: market.category || manifest.interface?.category || null,
|
|
390
|
+
displayName: manifest.interface?.displayName || manifest.name || item,
|
|
391
|
+
path: manifestPath,
|
|
392
|
+
tools: (manifest.tools || []).length,
|
|
393
|
+
prompts: (manifest.prompts || []).length
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return plugins;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function buildFileTree(basePath, depth = 0, maxDepth = 3) {
|
|
404
|
+
let stat;
|
|
405
|
+
try {
|
|
406
|
+
stat = fs.statSync(basePath);
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const node = {
|
|
412
|
+
name: path.basename(basePath) || basePath,
|
|
413
|
+
path: basePath,
|
|
414
|
+
isDir: stat.isDirectory(),
|
|
415
|
+
size: stat.isDirectory() ? null : stat.size,
|
|
416
|
+
children: []
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (!node.isDir || depth >= maxDepth) return node;
|
|
420
|
+
if (TREE_SKIP_DIRS.has(path.basename(basePath))) return node;
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const items = fs.readdirSync(basePath).sort();
|
|
424
|
+
for (const item of items) {
|
|
425
|
+
if (item.startsWith('.') && !['.codex', '.mcp.json'].includes(item)) continue;
|
|
426
|
+
const child = buildFileTree(path.join(basePath, item), depth + 1, maxDepth);
|
|
427
|
+
if (child) node.children.push(child);
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
return node;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return node;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function readPinned() {
|
|
437
|
+
const data = safeReadJson(PINNED_FILE);
|
|
438
|
+
return Array.isArray(data?.projects) ? data.projects : [];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function writePinned(projects) {
|
|
442
|
+
fs.writeFileSync(PINNED_FILE, JSON.stringify({ projects }, null, 2), 'utf8');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function readHistoryEntries(limit = 200) {
|
|
446
|
+
const filePath = path.join(CODEX_DIR, 'history.jsonl');
|
|
447
|
+
if (!fileExists(filePath)) return [];
|
|
448
|
+
|
|
449
|
+
const entries = [];
|
|
450
|
+
const lines = safeReadText(filePath)?.split('\n').filter(Boolean) || [];
|
|
451
|
+
for (const line of lines) {
|
|
452
|
+
try {
|
|
453
|
+
const row = JSON.parse(line);
|
|
454
|
+
entries.push({
|
|
455
|
+
sessionId: row.session_id || null,
|
|
456
|
+
timestamp: row.ts ? new Date(row.ts * 1000).toISOString() : null,
|
|
457
|
+
text: row.text || ''
|
|
458
|
+
});
|
|
459
|
+
} catch {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
entries.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
465
|
+
return entries.slice(0, limit);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function findSessionFile(sessionId) {
|
|
469
|
+
return walkSessionFiles().find(file => file.includes(sessionId)) || null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function readThreadRows(projectPath = null, includeArchived = false) {
|
|
473
|
+
if (!fileExists(STATE_DB)) return [];
|
|
474
|
+
const filters = [];
|
|
475
|
+
if (projectPath) filters.push(`cwd = ${sqlString(path.resolve(projectPath))}`);
|
|
476
|
+
if (!includeArchived) filters.push('archived = 0');
|
|
477
|
+
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
|
478
|
+
return querySqliteJson(STATE_DB, `
|
|
479
|
+
SELECT id, title, cwd, updated_at, created_at, archived, cli_version, model_provider
|
|
480
|
+
FROM threads
|
|
481
|
+
${where}
|
|
482
|
+
ORDER BY updated_at DESC;
|
|
483
|
+
`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function readSessionIndex() {
|
|
487
|
+
const filePath = path.join(CODEX_DIR, 'session_index.jsonl');
|
|
488
|
+
if (!fileExists(filePath)) return new Map();
|
|
489
|
+
|
|
490
|
+
const index = new Map();
|
|
491
|
+
const lines = safeReadText(filePath)?.split('\n').filter(Boolean) || [];
|
|
492
|
+
|
|
493
|
+
for (const line of lines) {
|
|
494
|
+
try {
|
|
495
|
+
const row = JSON.parse(line);
|
|
496
|
+
if (!row.id) continue;
|
|
497
|
+
index.set(row.id, {
|
|
498
|
+
threadName: row.thread_name || null,
|
|
499
|
+
updatedAt: row.updated_at || null
|
|
500
|
+
});
|
|
501
|
+
} catch {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return index;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function walkSessionFiles() {
|
|
510
|
+
const files = [];
|
|
511
|
+
if (!fileExists(SESSION_ROOT)) return files;
|
|
512
|
+
|
|
513
|
+
function walk(dirPath) {
|
|
514
|
+
let entries = [];
|
|
515
|
+
try {
|
|
516
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
517
|
+
} catch {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
523
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
524
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) files.push(fullPath);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
walk(SESSION_ROOT);
|
|
529
|
+
return files;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function toolNameFromEventType(type) {
|
|
533
|
+
const map = {
|
|
534
|
+
exec_command_end: 'exec_command',
|
|
535
|
+
patch_apply_end: 'apply_patch',
|
|
536
|
+
browser_snapshot_end: 'browser_snapshot',
|
|
537
|
+
browser_navigate_end: 'browser_navigate',
|
|
538
|
+
browser_click_end: 'browser_click'
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
return map[type] || null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function parseSessionFile(filePath, sessionIndex) {
|
|
545
|
+
const raw = safeReadText(filePath);
|
|
546
|
+
if (!raw) return null;
|
|
547
|
+
|
|
548
|
+
let meta = null;
|
|
549
|
+
let title = null;
|
|
550
|
+
let startedAt = null;
|
|
551
|
+
let endedAt = null;
|
|
552
|
+
let messageCount = 0;
|
|
553
|
+
let toolCallCount = 0;
|
|
554
|
+
const toolBreakdown = {};
|
|
555
|
+
|
|
556
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
557
|
+
let row;
|
|
558
|
+
try {
|
|
559
|
+
row = JSON.parse(line);
|
|
560
|
+
} catch {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (row.timestamp) {
|
|
565
|
+
if (!startedAt) startedAt = row.timestamp;
|
|
566
|
+
endedAt = row.timestamp;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (row.type === 'session_meta') {
|
|
570
|
+
meta = row.payload || {};
|
|
571
|
+
if (!startedAt && row.payload?.timestamp) startedAt = row.payload.timestamp;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (row.type === 'event_msg' && row.payload?.type === 'user_message') {
|
|
576
|
+
messageCount += 1;
|
|
577
|
+
if (!title && row.payload.message) title = String(row.payload.message).slice(0, 120);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (row.type === 'event_msg' && row.payload?.type === 'agent_message') {
|
|
582
|
+
messageCount += 1;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (row.type === 'event_msg') {
|
|
587
|
+
const tool = toolNameFromEventType(row.payload?.type);
|
|
588
|
+
if (!tool) continue;
|
|
589
|
+
toolCallCount += 1;
|
|
590
|
+
toolBreakdown[tool] = (toolBreakdown[tool] || 0) + 1;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (!meta?.id) return null;
|
|
595
|
+
|
|
596
|
+
const indexed = sessionIndex.get(meta.id) || {};
|
|
597
|
+
const stat = fs.statSync(filePath);
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
id: meta.id,
|
|
601
|
+
title: indexed.threadName || title || '(untitled session)',
|
|
602
|
+
cwd: meta.cwd || null,
|
|
603
|
+
cliVersion: meta.cli_version || null,
|
|
604
|
+
modelProvider: meta.model_provider || null,
|
|
605
|
+
source: meta.source || null,
|
|
606
|
+
startedAt: startedAt || meta.timestamp || null,
|
|
607
|
+
endedAt: endedAt || indexed.updatedAt || null,
|
|
608
|
+
updatedAt: indexed.updatedAt || endedAt || stat.mtime.toISOString(),
|
|
609
|
+
messageCount,
|
|
610
|
+
toolCallCount,
|
|
611
|
+
toolBreakdown,
|
|
612
|
+
filePath,
|
|
613
|
+
fileSize: stat.size
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function listSessions(projectPath = null) {
|
|
618
|
+
const sessionIndex = readSessionIndex();
|
|
619
|
+
const threadRows = new Map(readThreadRows(projectPath).map(row => [row.id, row]));
|
|
620
|
+
const files = walkSessionFiles();
|
|
621
|
+
const target = projectPath ? path.resolve(projectPath) : null;
|
|
622
|
+
const sessions = [];
|
|
623
|
+
|
|
624
|
+
for (const filePath of files) {
|
|
625
|
+
const session = parseSessionFile(filePath, sessionIndex);
|
|
626
|
+
if (!session) continue;
|
|
627
|
+
if (target && path.resolve(session.cwd || '') !== target) continue;
|
|
628
|
+
const row = threadRows.get(session.id);
|
|
629
|
+
if (row?.archived) continue;
|
|
630
|
+
if (row?.title) session.title = row.title;
|
|
631
|
+
if (row?.updated_at) session.updatedAt = new Date(row.updated_at * 1000).toISOString();
|
|
632
|
+
sessions.push(session);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
sessions.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
636
|
+
return sessions;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function readSessionDetail(sessionId) {
|
|
640
|
+
const sessionIndex = readSessionIndex();
|
|
641
|
+
const row = readThreadRows(null, true).find(item => item.id === sessionId) || null;
|
|
642
|
+
const filePath = walkSessionFiles().find(file => file.includes(sessionId));
|
|
643
|
+
if (!filePath) return null;
|
|
644
|
+
|
|
645
|
+
const raw = safeReadText(filePath);
|
|
646
|
+
if (!raw) return null;
|
|
647
|
+
|
|
648
|
+
const timeline = [];
|
|
649
|
+
const toolBreakdown = {};
|
|
650
|
+
let meta = null;
|
|
651
|
+
|
|
652
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
653
|
+
let row;
|
|
654
|
+
try {
|
|
655
|
+
row = JSON.parse(line);
|
|
656
|
+
} catch {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (row.type === 'session_meta') {
|
|
661
|
+
meta = row.payload || {};
|
|
662
|
+
timeline.push({
|
|
663
|
+
kind: 'meta',
|
|
664
|
+
timestamp: row.timestamp || row.payload?.timestamp || null,
|
|
665
|
+
title: 'Session started',
|
|
666
|
+
body: meta.cwd || ''
|
|
667
|
+
});
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (row.type === 'event_msg' && row.payload?.type === 'user_message') {
|
|
672
|
+
timeline.push({
|
|
673
|
+
kind: 'user',
|
|
674
|
+
timestamp: row.timestamp || null,
|
|
675
|
+
title: 'User',
|
|
676
|
+
body: row.payload.message || ''
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (row.type === 'event_msg' && row.payload?.type === 'agent_message') {
|
|
682
|
+
timeline.push({
|
|
683
|
+
kind: 'assistant',
|
|
684
|
+
timestamp: row.timestamp || null,
|
|
685
|
+
title: row.payload.phase === 'final_answer' ? 'Assistant Final' : 'Assistant Update',
|
|
686
|
+
body: row.payload.message || ''
|
|
687
|
+
});
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (row.type === 'event_msg') {
|
|
692
|
+
const tool = toolNameFromEventType(row.payload?.type);
|
|
693
|
+
if (!tool) continue;
|
|
694
|
+
toolBreakdown[tool] = (toolBreakdown[tool] || 0) + 1;
|
|
695
|
+
|
|
696
|
+
const command = Array.isArray(row.payload.command) ? row.payload.command.join(' ') : '';
|
|
697
|
+
const output = row.payload.output || row.payload.stdout || row.payload.stderr || row.payload.aggregated_output || '';
|
|
698
|
+
timeline.push({
|
|
699
|
+
kind: 'tool',
|
|
700
|
+
timestamp: row.timestamp || null,
|
|
701
|
+
title: tool,
|
|
702
|
+
body: excerpt(command || output, 600)
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const indexed = sessionIndex.get(sessionId) || {};
|
|
708
|
+
return {
|
|
709
|
+
session: {
|
|
710
|
+
id: sessionId,
|
|
711
|
+
title: row?.title || indexed.threadName || '(untitled session)',
|
|
712
|
+
cwd: row?.cwd || meta?.cwd || null,
|
|
713
|
+
cliVersion: row?.cli_version || meta?.cli_version || null,
|
|
714
|
+
modelProvider: row?.model_provider || meta?.model_provider || null,
|
|
715
|
+
startedAt: meta?.timestamp || null,
|
|
716
|
+
updatedAt: row?.updated_at ? new Date(row.updated_at * 1000).toISOString() : (indexed.updatedAt || null)
|
|
717
|
+
},
|
|
718
|
+
timeline,
|
|
719
|
+
summary: {
|
|
720
|
+
totalItems: timeline.length,
|
|
721
|
+
toolBreakdown
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function normalizeStatsRange({ days = 14, from = null, to = null } = {}) {
|
|
727
|
+
const end = to ? new Date(`${to}T23:59:59.999Z`) : new Date();
|
|
728
|
+
const start = from ? new Date(`${from}T00:00:00.000Z`) : new Date(end.getTime() - ((days - 1) * 24 * 60 * 60 * 1000));
|
|
729
|
+
return {
|
|
730
|
+
start,
|
|
731
|
+
end,
|
|
732
|
+
period: `${start.toISOString().slice(0, 10)}..${end.toISOString().slice(0, 10)}`
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function countToolUsage(projectPath = null, options = {}) {
|
|
737
|
+
const range = normalizeStatsRange(options);
|
|
738
|
+
const usage = {};
|
|
739
|
+
let scanned = 0;
|
|
740
|
+
|
|
741
|
+
for (const session of listSessions(projectPath)) {
|
|
742
|
+
const ts = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
|
|
743
|
+
if (!ts || ts < range.start.getTime() || ts > range.end.getTime()) continue;
|
|
744
|
+
scanned += 1;
|
|
745
|
+
for (const [tool, count] of Object.entries(session.toolBreakdown || {})) {
|
|
746
|
+
usage[tool] = (usage[tool] || 0) + count;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return { toolUsage: usage, sessionsScanned: scanned, period: range.period, from: range.start.toISOString().slice(0, 10), to: range.end.toISOString().slice(0, 10) };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function countDailyUsage(projectPath = null, options = {}) {
|
|
754
|
+
const target = projectPath ? path.resolve(projectPath) : null;
|
|
755
|
+
const range = normalizeStatsRange(options);
|
|
756
|
+
const byDate = new Map();
|
|
757
|
+
const filteredSessions = listSessions(target);
|
|
758
|
+
const allowedSessionIds = new Set(filteredSessions.map(session => session.id));
|
|
759
|
+
|
|
760
|
+
for (let ts = range.start.getTime(); ts <= range.end.getTime(); ts += 24 * 60 * 60 * 1000) {
|
|
761
|
+
const date = new Date(ts).toISOString().slice(0, 10);
|
|
762
|
+
byDate.set(date, { date, prompts: 0, sessions: 0, tools: 0 });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
for (const entry of readHistoryEntries(5000)) {
|
|
766
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
767
|
+
if (!ts || ts < range.start.getTime() || ts > range.end.getTime()) continue;
|
|
768
|
+
if (target && !allowedSessionIds.has(entry.sessionId)) continue;
|
|
769
|
+
const date = new Date(ts).toISOString().slice(0, 10);
|
|
770
|
+
const bucket = byDate.get(date);
|
|
771
|
+
if (bucket) bucket.prompts += 1;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
for (const session of filteredSessions) {
|
|
775
|
+
const ts = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
|
|
776
|
+
if (!ts || ts < range.start.getTime() || ts > range.end.getTime()) continue;
|
|
777
|
+
const date = new Date(ts).toISOString().slice(0, 10);
|
|
778
|
+
const bucket = byDate.get(date);
|
|
779
|
+
if (!bucket) continue;
|
|
780
|
+
bucket.sessions += 1;
|
|
781
|
+
bucket.tools += session.toolCallCount || 0;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return Array.from(byDate.values());
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function buildUsageStats(projectPath = null, options = {}) {
|
|
788
|
+
const daily = countDailyUsage(projectPath, options);
|
|
789
|
+
const tools = countToolUsage(projectPath, options);
|
|
790
|
+
return {
|
|
791
|
+
period: tools.period,
|
|
792
|
+
from: tools.from,
|
|
793
|
+
to: tools.to,
|
|
794
|
+
daily,
|
|
795
|
+
topTools: Object.entries(tools.toolUsage)
|
|
796
|
+
.sort((a, b) => b[1] - a[1])
|
|
797
|
+
.slice(0, 8)
|
|
798
|
+
.map(([name, count]) => ({ name, count })),
|
|
799
|
+
sessionsScanned: tools.sessionsScanned
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function loadMarketplace() {
|
|
804
|
+
return safeReadJson(MARKETPLACE_PATH) || {
|
|
805
|
+
name: 'openai-curated',
|
|
806
|
+
interface: { displayName: 'Codex official' },
|
|
807
|
+
plugins: []
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function writeMarketplace(data) {
|
|
812
|
+
writeText(MARKETPLACE_PATH, JSON.stringify(data, null, 2));
|
|
813
|
+
invalidateCache();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function pluginManifestPath(name) {
|
|
817
|
+
return path.join(PLUGINS_ROOT, sanitizeSkillName(name), '.codex-plugin', 'plugin.json');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function writePluginManifest(name, data) {
|
|
821
|
+
const manifestPath = pluginManifestPath(name);
|
|
822
|
+
writeText(manifestPath, JSON.stringify(data, null, 2));
|
|
823
|
+
return manifestPath;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function buildPluginManifest(input) {
|
|
827
|
+
const safeName = sanitizeSkillName(input.name);
|
|
828
|
+
return {
|
|
829
|
+
name: safeName,
|
|
830
|
+
version: input.version || '0.1.0',
|
|
831
|
+
description: input.description || '',
|
|
832
|
+
author: {
|
|
833
|
+
name: input.authorName || 'Codex Map',
|
|
834
|
+
email: input.authorEmail || '',
|
|
835
|
+
url: input.authorUrl || ''
|
|
836
|
+
},
|
|
837
|
+
homepage: input.homepage || '',
|
|
838
|
+
repository: input.repository || '',
|
|
839
|
+
license: input.license || 'MIT',
|
|
840
|
+
keywords: ensureArray(input.keywords).filter(Boolean),
|
|
841
|
+
interface: {
|
|
842
|
+
displayName: input.displayName || safeName,
|
|
843
|
+
shortDescription: input.shortDescription || input.description || '',
|
|
844
|
+
longDescription: input.longDescription || input.description || '',
|
|
845
|
+
developerName: input.authorName || 'Codex Map',
|
|
846
|
+
category: input.category || 'Custom',
|
|
847
|
+
capabilities: ensureArray(input.capabilities).filter(Boolean),
|
|
848
|
+
websiteURL: input.homepage || '',
|
|
849
|
+
privacyPolicyURL: input.privacyPolicyURL || '',
|
|
850
|
+
termsOfServiceURL: input.termsOfServiceURL || '',
|
|
851
|
+
defaultPrompt: ensureArray(input.defaultPrompt).filter(Boolean)
|
|
852
|
+
},
|
|
853
|
+
skills: './skills/',
|
|
854
|
+
apps: './.app.json'
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function getConfiguredProjects(config) {
|
|
859
|
+
const sessionsByPath = new Map();
|
|
860
|
+
for (const session of listSessions()) {
|
|
861
|
+
if (!session.cwd) continue;
|
|
862
|
+
const key = path.resolve(session.cwd);
|
|
863
|
+
sessionsByPath.set(key, (sessionsByPath.get(key) || 0) + 1);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return (config?.projects || []).map(project => {
|
|
867
|
+
const resolved = path.resolve(project.path);
|
|
868
|
+
const hasAgents = fileExists(path.join(resolved, 'AGENTS.md'));
|
|
869
|
+
const hasCodexDir = fileExists(path.join(resolved, '.codex'));
|
|
870
|
+
const hasMcp = fileExists(path.join(resolved, '.mcp.json')) || fileExists(path.join(resolved, '.codex', '.mcp.json'));
|
|
871
|
+
const exists = fileExists(resolved);
|
|
872
|
+
const score = [hasAgents, hasCodexDir, hasMcp].filter(Boolean).length;
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
path: resolved,
|
|
876
|
+
name: path.basename(resolved),
|
|
877
|
+
trustLevel: project.trustLevel || null,
|
|
878
|
+
exists,
|
|
879
|
+
hasAgents,
|
|
880
|
+
hasCodexDir,
|
|
881
|
+
hasMcp,
|
|
882
|
+
sessionCount: sessionsByPath.get(resolved) || 0,
|
|
883
|
+
status: !exists ? 'missing' : score >= 2 ? 'full' : score === 1 ? 'partial' : 'none'
|
|
884
|
+
};
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function readProjectConfig(projectPath, config) {
|
|
889
|
+
const resolved = path.resolve(projectPath);
|
|
890
|
+
const projectCodexDir = path.join(resolved, '.codex');
|
|
891
|
+
const trust = (config?.projects || []).find(item => path.resolve(item.path) === resolved) || null;
|
|
892
|
+
|
|
893
|
+
return {
|
|
894
|
+
path: resolved,
|
|
895
|
+
projectName: path.basename(resolved),
|
|
896
|
+
hasCodexDir: fileExists(projectCodexDir),
|
|
897
|
+
agentsMd: readAgentsMd(resolved) || readAgentsMd(projectCodexDir),
|
|
898
|
+
mcpJson: readMcpJson(resolved),
|
|
899
|
+
trustLevel: trust?.trustLevel || null,
|
|
900
|
+
localSkills: fileExists(projectCodexDir) ? readSkillsDir(projectCodexDir) : [],
|
|
901
|
+
fileTree: buildFileTree(resolved, 0, 3)
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function buildProjectAnalysis(projectPath) {
|
|
906
|
+
const resolved = path.resolve(projectPath);
|
|
907
|
+
const config = readConfigToml();
|
|
908
|
+
const exists = fileExists(resolved);
|
|
909
|
+
|
|
910
|
+
if (!exists) {
|
|
911
|
+
return {
|
|
912
|
+
project: {
|
|
913
|
+
path: resolved,
|
|
914
|
+
name: path.basename(resolved),
|
|
915
|
+
exists: false,
|
|
916
|
+
hasCodexDir: false,
|
|
917
|
+
hasAgentsMd: false,
|
|
918
|
+
hasMcpJson: false,
|
|
919
|
+
trustLevel: null,
|
|
920
|
+
sessionCount: 0,
|
|
921
|
+
status: 'missing',
|
|
922
|
+
warnings: [{ level: 'error', message: 'Path does not exist on disk' }]
|
|
923
|
+
},
|
|
924
|
+
connections: {
|
|
925
|
+
global: {
|
|
926
|
+
skills: { count: readSkillsDir(CODEX_DIR).length },
|
|
927
|
+
mcp: { count: config?.mcpServers?.length || 0 },
|
|
928
|
+
projects: { count: config?.projects?.length || 0 },
|
|
929
|
+
plugins: { count: readPluginManifests().length }
|
|
930
|
+
},
|
|
931
|
+
local: {
|
|
932
|
+
agentsMd: { present: false },
|
|
933
|
+
skills: { count: 0 },
|
|
934
|
+
mcpJson: { present: false }
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const project = readProjectConfig(resolved, config);
|
|
941
|
+
const sessions = listSessions(resolved);
|
|
942
|
+
const score = [project.hasCodexDir, !!project.agentsMd, !!project.mcpJson, !!project.trustLevel].filter(Boolean).length;
|
|
943
|
+
const warnings = [];
|
|
944
|
+
|
|
945
|
+
if (!project.trustLevel) warnings.push({ level: 'info', message: 'Project is not listed in ~/.codex/config.toml' });
|
|
946
|
+
if (!project.agentsMd) warnings.push({ level: 'warning', message: 'No AGENTS.md found for project-specific instructions' });
|
|
947
|
+
if (!project.mcpJson) warnings.push({ level: 'info', message: 'No .mcp.json found for this project' });
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
project: {
|
|
951
|
+
path: resolved,
|
|
952
|
+
name: path.basename(resolved),
|
|
953
|
+
exists: true,
|
|
954
|
+
hasCodexDir: project.hasCodexDir,
|
|
955
|
+
hasAgentsMd: !!project.agentsMd,
|
|
956
|
+
hasMcpJson: !!project.mcpJson,
|
|
957
|
+
trustLevel: project.trustLevel,
|
|
958
|
+
sessionCount: sessions.length,
|
|
959
|
+
status: score >= 3 ? 'full' : score >= 1 ? 'partial' : 'none',
|
|
960
|
+
warnings
|
|
961
|
+
},
|
|
962
|
+
connections: {
|
|
963
|
+
global: {
|
|
964
|
+
skills: { count: readSkillsDir(CODEX_DIR).length },
|
|
965
|
+
mcp: { count: config?.mcpServers?.length || 0 },
|
|
966
|
+
projects: { count: config?.projects?.length || 0 },
|
|
967
|
+
plugins: { count: readPluginManifests().length }
|
|
968
|
+
},
|
|
969
|
+
local: {
|
|
970
|
+
agentsMd: { present: !!project.agentsMd },
|
|
971
|
+
skills: { count: project.localSkills.length },
|
|
972
|
+
mcpJson: { present: !!project.mcpJson, servers: project.mcpJson?.servers || [] }
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function buildScanResult(projectPath = null) {
|
|
979
|
+
const started = Date.now();
|
|
980
|
+
const config = readConfigToml();
|
|
981
|
+
|
|
982
|
+
const result = {
|
|
983
|
+
meta: {
|
|
984
|
+
scannedAt: new Date().toISOString(),
|
|
985
|
+
globalPath: CODEX_DIR,
|
|
986
|
+
projectPath: projectPath ? path.resolve(projectPath) : null,
|
|
987
|
+
scanDurationMs: 0
|
|
988
|
+
},
|
|
989
|
+
global: {
|
|
990
|
+
agentsMd: readAgentsMd(CODEX_DIR),
|
|
991
|
+
config,
|
|
992
|
+
skills: readSkillsDir(CODEX_DIR),
|
|
993
|
+
plugins: readPluginManifests(),
|
|
994
|
+
history: readHistoryEntries(300),
|
|
995
|
+
projects: getConfiguredProjects(config),
|
|
996
|
+
sessionSummary: {
|
|
997
|
+
total: listSessions().length
|
|
998
|
+
},
|
|
999
|
+
fileTree: buildFileTree(CODEX_DIR, 0, 3)
|
|
1000
|
+
},
|
|
1001
|
+
project: projectPath ? readProjectConfig(projectPath, config) : null
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
result.meta.scanDurationMs = Date.now() - started;
|
|
1005
|
+
return result;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function broadcastSSE(event, data) {
|
|
1009
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
1010
|
+
for (const res of sseClients) {
|
|
1011
|
+
try {
|
|
1012
|
+
res.write(payload);
|
|
1013
|
+
} catch {
|
|
1014
|
+
sseClients.delete(res);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
setInterval(() => broadcastSSE('heartbeat', { ts: Date.now() }), 30000);
|
|
1020
|
+
|
|
1021
|
+
const watcher = chokidar.watch([
|
|
1022
|
+
path.join(CODEX_DIR, 'config.toml'),
|
|
1023
|
+
path.join(CODEX_DIR, 'history.jsonl'),
|
|
1024
|
+
path.join(CODEX_DIR, 'session_index.jsonl'),
|
|
1025
|
+
path.join(CODEX_DIR, 'skills'),
|
|
1026
|
+
path.join(CODEX_DIR, '.tmp', 'plugins', 'plugins')
|
|
1027
|
+
].filter(fileExists), {
|
|
1028
|
+
persistent: true,
|
|
1029
|
+
ignoreInitial: true,
|
|
1030
|
+
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
watcher.on('all', (event, filePath) => {
|
|
1034
|
+
invalidateCache();
|
|
1035
|
+
broadcastSSE('file-changed', { event, path: filePath });
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
function isPathAllowed(requestedPath, projectPath) {
|
|
1039
|
+
const resolved = path.resolve(requestedPath);
|
|
1040
|
+
const allowed = [CODEX_DIR];
|
|
1041
|
+
if (projectPath) allowed.push(path.resolve(projectPath));
|
|
1042
|
+
return allowed.some(base => resolved === base || resolved.startsWith(`${base}${path.sep}`));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function inspectProjectPath(projectPath) {
|
|
1046
|
+
const resolved = path.resolve(projectPath);
|
|
1047
|
+
const hasAgents = fileExists(path.join(resolved, 'AGENTS.md'));
|
|
1048
|
+
const hasCodexDir = fileExists(path.join(resolved, '.codex'));
|
|
1049
|
+
const hasMcp = fileExists(path.join(resolved, '.mcp.json')) || fileExists(path.join(resolved, '.codex', '.mcp.json'));
|
|
1050
|
+
const score = [hasAgents, hasCodexDir, hasMcp].filter(Boolean).length;
|
|
1051
|
+
return {
|
|
1052
|
+
hasAgents,
|
|
1053
|
+
hasCodexDir,
|
|
1054
|
+
hasMcp,
|
|
1055
|
+
score,
|
|
1056
|
+
status: score >= 2 ? 'full' : score === 1 ? 'partial' : 'none',
|
|
1057
|
+
isProject: score > 0
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function discoverProjectPaths(rootPath, showHidden, maxDepth = 4, maxResults = 80) {
|
|
1062
|
+
const root = path.resolve(rootPath);
|
|
1063
|
+
const found = [];
|
|
1064
|
+
const seen = new Set();
|
|
1065
|
+
|
|
1066
|
+
function walk(currentPath, depth) {
|
|
1067
|
+
if (found.length >= maxResults || depth > maxDepth) return;
|
|
1068
|
+
|
|
1069
|
+
let entries = [];
|
|
1070
|
+
try {
|
|
1071
|
+
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
1072
|
+
} catch {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
for (const entry of entries) {
|
|
1077
|
+
if (!entry.isDirectory()) continue;
|
|
1078
|
+
if (!showHidden && entry.name.startsWith('.')) continue;
|
|
1079
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
1080
|
+
const meta = inspectProjectPath(fullPath);
|
|
1081
|
+
if (meta.isProject && !seen.has(fullPath)) {
|
|
1082
|
+
seen.add(fullPath);
|
|
1083
|
+
found.push({
|
|
1084
|
+
name: path.basename(fullPath),
|
|
1085
|
+
path: fullPath,
|
|
1086
|
+
...meta
|
|
1087
|
+
});
|
|
1088
|
+
if (found.length >= maxResults) return;
|
|
1089
|
+
}
|
|
1090
|
+
walk(fullPath, depth + 1);
|
|
1091
|
+
if (found.length >= maxResults) return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
walk(root, 1);
|
|
1096
|
+
return found;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function browseDir(dirPath, showHidden) {
|
|
1100
|
+
const resolved = path.resolve(dirPath);
|
|
1101
|
+
const config = readConfigToml();
|
|
1102
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
1103
|
+
const dirs = entries
|
|
1104
|
+
.filter(entry => entry.isDirectory())
|
|
1105
|
+
.filter(entry => showHidden || !entry.name.startsWith('.'))
|
|
1106
|
+
.map(entry => {
|
|
1107
|
+
const fullPath = path.join(resolved, entry.name);
|
|
1108
|
+
const meta = inspectProjectPath(fullPath);
|
|
1109
|
+
return {
|
|
1110
|
+
name: entry.name,
|
|
1111
|
+
path: fullPath,
|
|
1112
|
+
...meta
|
|
1113
|
+
};
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const projectDirs = dirs.filter(dir => dir.isProject);
|
|
1117
|
+
const discovered = discoverProjectPaths(resolved, showHidden)
|
|
1118
|
+
.filter(item => item.path !== resolved && !projectDirs.some(dir => dir.path === item.path));
|
|
1119
|
+
const trustedProjects = getConfiguredProjects(config)
|
|
1120
|
+
.filter(item => item.exists && item.path.startsWith(`${resolved}${path.sep}`))
|
|
1121
|
+
.map(item => ({
|
|
1122
|
+
name: item.name,
|
|
1123
|
+
path: item.path,
|
|
1124
|
+
status: item.status,
|
|
1125
|
+
isProject: item.status !== 'none',
|
|
1126
|
+
score: item.status === 'full' ? 3 : item.status === 'partial' ? 1 : 0,
|
|
1127
|
+
trusted: true
|
|
1128
|
+
}))
|
|
1129
|
+
.filter(item => !projectDirs.some(dir => dir.path === item.path) && !discovered.some(dir => dir.path === item.path));
|
|
1130
|
+
const visibleDirs = projectDirs.length ? projectDirs : dirs;
|
|
1131
|
+
visibleDirs.sort((a, b) => {
|
|
1132
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
1133
|
+
return a.name.localeCompare(b.name);
|
|
1134
|
+
});
|
|
1135
|
+
discovered.sort((a, b) => {
|
|
1136
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
1137
|
+
return a.path.localeCompare(b.path);
|
|
1138
|
+
});
|
|
1139
|
+
trustedProjects.sort((a, b) => a.path.localeCompare(b.path));
|
|
1140
|
+
|
|
1141
|
+
const root = path.parse(resolved).root;
|
|
1142
|
+
const rel = path.relative(root, resolved);
|
|
1143
|
+
const parts = rel ? rel.split(path.sep) : [];
|
|
1144
|
+
const crumbs = [{ name: root || '/', path: root || '/' }];
|
|
1145
|
+
let acc = root;
|
|
1146
|
+
|
|
1147
|
+
for (const part of parts) {
|
|
1148
|
+
acc = path.join(acc, part);
|
|
1149
|
+
crumbs.push({ name: part, path: acc });
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
current: resolved,
|
|
1154
|
+
parent: resolved !== root ? path.dirname(resolved) : null,
|
|
1155
|
+
crumbs,
|
|
1156
|
+
dirs: visibleDirs,
|
|
1157
|
+
codexOnly: projectDirs.length > 0,
|
|
1158
|
+
discovered,
|
|
1159
|
+
trustedProjects
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
1164
|
+
|
|
1165
|
+
app.get('/api/pinned-projects', (req, res) => {
|
|
1166
|
+
res.json({ projects: readPinned() });
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
app.post('/api/pinned-projects', (req, res) => {
|
|
1170
|
+
const projectPath = req.body?.path;
|
|
1171
|
+
if (!projectPath || typeof projectPath !== 'string') {
|
|
1172
|
+
return res.status(400).json({ error: 'Missing path' });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const resolved = path.resolve(projectPath);
|
|
1176
|
+
const projects = readPinned();
|
|
1177
|
+
if (!projects.includes(resolved)) {
|
|
1178
|
+
projects.push(resolved);
|
|
1179
|
+
writePinned(projects);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
res.json({ projects });
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
app.delete('/api/pinned-projects', (req, res) => {
|
|
1186
|
+
const projectPath = req.body?.path;
|
|
1187
|
+
if (!projectPath) return res.status(400).json({ error: 'Missing path' });
|
|
1188
|
+
const resolved = path.resolve(projectPath);
|
|
1189
|
+
const projects = readPinned().filter(item => item !== resolved);
|
|
1190
|
+
writePinned(projects);
|
|
1191
|
+
res.json({ projects });
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
app.get('/api/browse', (req, res) => {
|
|
1195
|
+
const dirPath = req.query.path || os.homedir();
|
|
1196
|
+
const showHidden = req.query.hidden === '1';
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
res.json(browseDir(dirPath, showHidden));
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
const parent = path.dirname(path.resolve(dirPath));
|
|
1202
|
+
try {
|
|
1203
|
+
res.json({ ...browseDir(parent, showHidden), error: error.message });
|
|
1204
|
+
} catch {
|
|
1205
|
+
res.status(403).json({ error: error.message });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
app.get('/api/browse/bookmarks', (req, res) => {
|
|
1211
|
+
const home = os.homedir();
|
|
1212
|
+
const bookmarks = [
|
|
1213
|
+
{ name: 'Home', path: home },
|
|
1214
|
+
{ name: 'Projects', path: '/Volumes/Projects' },
|
|
1215
|
+
{ name: 'Codex Home', path: CODEX_DIR },
|
|
1216
|
+
{ name: 'Desktop', path: path.join(home, 'Desktop') }
|
|
1217
|
+
].filter(item => fileExists(item.path));
|
|
1218
|
+
|
|
1219
|
+
res.json({ bookmarks });
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
app.get('/api/scan', async (req, res) => {
|
|
1223
|
+
try {
|
|
1224
|
+
res.json(await getCachedScan(req.query.project || null));
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
res.status(500).json({ error: error.message, code: 'SCAN_FAILED' });
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
app.get('/api/project-status', (req, res) => {
|
|
1231
|
+
const projectPath = req.query.path;
|
|
1232
|
+
if (!projectPath) return res.status(400).json({ error: 'Missing path' });
|
|
1233
|
+
|
|
1234
|
+
const resolved = path.resolve(projectPath);
|
|
1235
|
+
if (!fileExists(resolved)) return res.json({ status: 'missing' });
|
|
1236
|
+
const meta = inspectProjectPath(resolved);
|
|
1237
|
+
res.json({ status: meta.status, ...meta });
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
app.get('/api/analyze', (req, res) => {
|
|
1241
|
+
const projectPath = req.query.project;
|
|
1242
|
+
if (!projectPath) return res.status(400).json({ error: 'Missing project parameter' });
|
|
1243
|
+
|
|
1244
|
+
try {
|
|
1245
|
+
res.json(buildProjectAnalysis(projectPath));
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
res.status(500).json({ error: error.message });
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
app.get('/api/file', (req, res) => {
|
|
1252
|
+
const filePath = req.query.path;
|
|
1253
|
+
const projectPath = req.query.project || null;
|
|
1254
|
+
if (!filePath) return res.status(400).json({ error: 'Missing path' });
|
|
1255
|
+
if (!isPathAllowed(filePath, projectPath)) return res.status(403).json({ error: 'Forbidden' });
|
|
1256
|
+
|
|
1257
|
+
try {
|
|
1258
|
+
const stat = fs.statSync(filePath);
|
|
1259
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
1260
|
+
const content = fs.readFileSync(filePath, 'utf8').slice(0, MAX_FILE_BYTES);
|
|
1261
|
+
return res.json({
|
|
1262
|
+
content: `${content}\n\n[... file truncated ...]`,
|
|
1263
|
+
size: stat.size,
|
|
1264
|
+
truncated: true,
|
|
1265
|
+
mtime: stat.mtime.toISOString()
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
res.json({
|
|
1270
|
+
content: fs.readFileSync(filePath, 'utf8'),
|
|
1271
|
+
size: stat.size,
|
|
1272
|
+
truncated: false,
|
|
1273
|
+
mtime: stat.mtime.toISOString()
|
|
1274
|
+
});
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
res.status(404).json({ error: error.message });
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
app.put('/api/file', (req, res) => {
|
|
1281
|
+
const filePath = req.body?.path;
|
|
1282
|
+
const content = req.body?.content;
|
|
1283
|
+
const projectPath = req.body?.projectPath || null;
|
|
1284
|
+
if (!filePath || typeof content !== 'string') return res.status(400).json({ error: 'Missing path or content' });
|
|
1285
|
+
if (!isPathAllowed(filePath, projectPath)) return res.status(403).json({ error: 'Forbidden' });
|
|
1286
|
+
|
|
1287
|
+
try {
|
|
1288
|
+
writeText(filePath, content);
|
|
1289
|
+
invalidateCache();
|
|
1290
|
+
res.json({ ok: true, path: filePath });
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
res.status(500).json({ error: error.message });
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
app.get('/api/export', async (req, res) => {
|
|
1297
|
+
try {
|
|
1298
|
+
const data = await getCachedScan(req.query.project || null);
|
|
1299
|
+
const filename = `codex-map-${new Date().toISOString().slice(0, 10)}.json`;
|
|
1300
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
1301
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1302
|
+
res.send(JSON.stringify(data, null, 2));
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
res.status(500).json({ error: error.message });
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
app.get('/api/config', (req, res) => {
|
|
1309
|
+
res.json(readConfigToml());
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
app.put('/api/config', (req, res) => {
|
|
1313
|
+
const raw = req.body?.raw;
|
|
1314
|
+
if (typeof raw !== 'string') return res.status(400).json({ error: 'Missing raw config' });
|
|
1315
|
+
try {
|
|
1316
|
+
const data = TOML.parse(raw);
|
|
1317
|
+
writeText(path.join(CODEX_DIR, 'config.toml'), TOML.stringify(data));
|
|
1318
|
+
invalidateCache();
|
|
1319
|
+
res.json(readConfigToml());
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
res.status(400).json({ error: error.message });
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
app.post('/api/config/mcp', (req, res) => {
|
|
1326
|
+
try {
|
|
1327
|
+
const { name, command, args, env, cwd } = req.body || {};
|
|
1328
|
+
if (!name || !command) return res.status(400).json({ error: 'Missing name or command' });
|
|
1329
|
+
const doc = readConfigTomlDoc();
|
|
1330
|
+
doc.data.mcp_servers = doc.data.mcp_servers || {};
|
|
1331
|
+
doc.data.mcp_servers[name] = {
|
|
1332
|
+
command,
|
|
1333
|
+
args: ensureArray(args).filter(Boolean),
|
|
1334
|
+
cwd: cwd || undefined,
|
|
1335
|
+
env: env || undefined
|
|
1336
|
+
};
|
|
1337
|
+
res.json(writeConfigTomlData(doc.data));
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
res.status(400).json({ error: error.message });
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
app.put('/api/config/mcp/:name', (req, res) => {
|
|
1344
|
+
try {
|
|
1345
|
+
const { newName, command, args, env, cwd } = req.body || {};
|
|
1346
|
+
const doc = readConfigTomlDoc();
|
|
1347
|
+
doc.data.mcp_servers = doc.data.mcp_servers || {};
|
|
1348
|
+
const existing = doc.data.mcp_servers[req.params.name];
|
|
1349
|
+
if (!existing) return res.status(404).json({ error: 'MCP server not found' });
|
|
1350
|
+
delete doc.data.mcp_servers[req.params.name];
|
|
1351
|
+
doc.data.mcp_servers[newName || req.params.name] = {
|
|
1352
|
+
command,
|
|
1353
|
+
args: ensureArray(args).filter(Boolean),
|
|
1354
|
+
cwd: cwd || undefined,
|
|
1355
|
+
env: env || undefined
|
|
1356
|
+
};
|
|
1357
|
+
res.json(writeConfigTomlData(doc.data));
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
res.status(400).json({ error: error.message });
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
app.delete('/api/config/mcp/:name', (req, res) => {
|
|
1364
|
+
try {
|
|
1365
|
+
const doc = readConfigTomlDoc();
|
|
1366
|
+
if (!doc.data.mcp_servers?.[req.params.name]) return res.status(404).json({ error: 'MCP server not found' });
|
|
1367
|
+
delete doc.data.mcp_servers[req.params.name];
|
|
1368
|
+
res.json(writeConfigTomlData(doc.data));
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
res.status(400).json({ error: error.message });
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
app.post('/api/config/projects', (req, res) => {
|
|
1375
|
+
try {
|
|
1376
|
+
const projectPath = req.body?.path;
|
|
1377
|
+
const trustLevel = req.body?.trustLevel || 'trusted';
|
|
1378
|
+
if (!projectPath) return res.status(400).json({ error: 'Missing path' });
|
|
1379
|
+
const resolved = path.resolve(projectPath);
|
|
1380
|
+
const doc = readConfigTomlDoc();
|
|
1381
|
+
doc.data.projects = doc.data.projects || {};
|
|
1382
|
+
doc.data.projects[resolved] = { ...(doc.data.projects[resolved] || {}), trust_level: trustLevel };
|
|
1383
|
+
res.json(writeConfigTomlData(doc.data));
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
res.status(400).json({ error: error.message });
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
app.put('/api/config/projects', (req, res) => {
|
|
1390
|
+
try {
|
|
1391
|
+
const projectPath = req.body?.path;
|
|
1392
|
+
const trustLevel = req.body?.trustLevel || 'trusted';
|
|
1393
|
+
if (!projectPath) return res.status(400).json({ error: 'Missing path' });
|
|
1394
|
+
const resolved = path.resolve(projectPath);
|
|
1395
|
+
const doc = readConfigTomlDoc();
|
|
1396
|
+
if (!doc.data.projects?.[resolved]) return res.status(404).json({ error: 'Project not found' });
|
|
1397
|
+
doc.data.projects[resolved] = { ...(doc.data.projects[resolved] || {}), trust_level: trustLevel };
|
|
1398
|
+
res.json(writeConfigTomlData(doc.data));
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
res.status(400).json({ error: error.message });
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
app.delete('/api/config/projects', (req, res) => {
|
|
1405
|
+
try {
|
|
1406
|
+
const projectPath = req.body?.path;
|
|
1407
|
+
if (!projectPath) return res.status(400).json({ error: 'Missing path' });
|
|
1408
|
+
const resolved = path.resolve(projectPath);
|
|
1409
|
+
const doc = readConfigTomlDoc();
|
|
1410
|
+
if (!doc.data.projects?.[resolved]) return res.status(404).json({ error: 'Project not found' });
|
|
1411
|
+
delete doc.data.projects[resolved];
|
|
1412
|
+
res.json(writeConfigTomlData(doc.data));
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
res.status(400).json({ error: error.message });
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
app.get('/api/skills', (req, res) => {
|
|
1419
|
+
try {
|
|
1420
|
+
const scope = req.query.scope === 'project' ? 'project' : 'global';
|
|
1421
|
+
const baseDir = getSkillsBaseDir(scope, req.query.projectPath || req.query.project || null);
|
|
1422
|
+
res.json({ skills: readSkillsDir(path.dirname(baseDir)) });
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
res.status(400).json({ error: error.message });
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
app.get('/api/skills/:name', (req, res) => {
|
|
1429
|
+
try {
|
|
1430
|
+
const scope = req.query.scope === 'project' ? 'project' : 'global';
|
|
1431
|
+
const baseDir = getSkillsBaseDir(scope, req.query.projectPath || req.query.project || null);
|
|
1432
|
+
const filePath = resolveSkillFile(baseDir, req.params.name);
|
|
1433
|
+
if (!fileExists(filePath)) return res.status(404).json({ error: 'Skill not found' });
|
|
1434
|
+
res.json({ name: req.params.name, path: filePath, content: safeReadText(filePath) });
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
res.status(400).json({ error: error.message });
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
app.post('/api/skills', (req, res) => {
|
|
1441
|
+
try {
|
|
1442
|
+
const { name, content, scope, projectPath } = req.body || {};
|
|
1443
|
+
if (!name || !content) return res.status(400).json({ error: 'Missing name or content' });
|
|
1444
|
+
const baseDir = getSkillsBaseDir(scope === 'project' ? 'project' : 'global', projectPath || null);
|
|
1445
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
1446
|
+
const filePath = resolveSkillFile(baseDir, name);
|
|
1447
|
+
if (fileExists(filePath)) return res.status(409).json({ error: 'Skill already exists' });
|
|
1448
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
1449
|
+
invalidateCache();
|
|
1450
|
+
res.json({ ok: true, path: filePath });
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
res.status(400).json({ error: error.message });
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
app.put('/api/skills/:name', (req, res) => {
|
|
1457
|
+
try {
|
|
1458
|
+
const { content, scope, projectPath } = req.body || {};
|
|
1459
|
+
if (!content) return res.status(400).json({ error: 'Missing content' });
|
|
1460
|
+
const baseDir = getSkillsBaseDir(scope === 'project' ? 'project' : 'global', projectPath || null);
|
|
1461
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
1462
|
+
const filePath = resolveSkillFile(baseDir, req.params.name);
|
|
1463
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
1464
|
+
invalidateCache();
|
|
1465
|
+
res.json({ ok: true, path: filePath });
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
res.status(400).json({ error: error.message });
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
app.delete('/api/skills/:name', (req, res) => {
|
|
1472
|
+
try {
|
|
1473
|
+
const scope = req.query.scope === 'project' ? 'project' : 'global';
|
|
1474
|
+
const baseDir = getSkillsBaseDir(scope, req.query.projectPath || null);
|
|
1475
|
+
const filePath = resolveSkillFile(baseDir, req.params.name);
|
|
1476
|
+
if (!fileExists(filePath)) return res.status(404).json({ error: 'Skill not found' });
|
|
1477
|
+
fs.unlinkSync(filePath);
|
|
1478
|
+
invalidateCache();
|
|
1479
|
+
res.json({ ok: true });
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
res.status(400).json({ error: error.message });
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
app.post('/api/export/bundle', (req, res) => {
|
|
1486
|
+
const { scope, projectPath } = req.body || {};
|
|
1487
|
+
const isProject = scope === 'project' && projectPath;
|
|
1488
|
+
const baseDir = isProject ? path.join(path.resolve(projectPath), '.codex') : CODEX_DIR;
|
|
1489
|
+
|
|
1490
|
+
const bundle = {
|
|
1491
|
+
version: 1,
|
|
1492
|
+
type: 'codex-map-bundle',
|
|
1493
|
+
exportedAt: new Date().toISOString(),
|
|
1494
|
+
source: {
|
|
1495
|
+
scope: isProject ? 'project' : 'global',
|
|
1496
|
+
path: isProject ? path.resolve(projectPath) : CODEX_DIR
|
|
1497
|
+
},
|
|
1498
|
+
skills: readSkillsDir(baseDir).map(skill => ({ name: skill.name, raw: skill.raw })),
|
|
1499
|
+
agentsMd: isProject
|
|
1500
|
+
? (readAgentsMd(path.resolve(projectPath))?.raw || null)
|
|
1501
|
+
: (readAgentsMd(CODEX_DIR)?.raw || null)
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
const filename = `codex-map-bundle-${new Date().toISOString().slice(0, 10)}.json`;
|
|
1505
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
1506
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1507
|
+
res.send(JSON.stringify(bundle, null, 2));
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
app.get('/api/plugins', (req, res) => {
|
|
1511
|
+
res.json({ plugins: readPluginManifests(), marketplace: loadMarketplace() });
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
app.post('/api/plugins', (req, res) => {
|
|
1515
|
+
try {
|
|
1516
|
+
const manifest = buildPluginManifest(req.body || {});
|
|
1517
|
+
const safeName = manifest.name;
|
|
1518
|
+
const marketplace = loadMarketplace();
|
|
1519
|
+
if ((marketplace.plugins || []).some(item => item.name === safeName)) {
|
|
1520
|
+
return res.status(409).json({ error: 'Plugin already exists' });
|
|
1521
|
+
}
|
|
1522
|
+
writePluginManifest(safeName, manifest);
|
|
1523
|
+
marketplace.plugins = marketplace.plugins || [];
|
|
1524
|
+
marketplace.plugins.push({
|
|
1525
|
+
name: safeName,
|
|
1526
|
+
source: { source: 'local', path: `./plugins/${safeName}` },
|
|
1527
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
1528
|
+
category: req.body?.category || 'Custom'
|
|
1529
|
+
});
|
|
1530
|
+
writeMarketplace(marketplace);
|
|
1531
|
+
res.json({ ok: true, plugin: safeName });
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
res.status(400).json({ error: error.message });
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
app.put('/api/plugins/:name', (req, res) => {
|
|
1538
|
+
try {
|
|
1539
|
+
const oldName = sanitizeSkillName(req.params.name);
|
|
1540
|
+
const nextManifest = buildPluginManifest(req.body || {});
|
|
1541
|
+
const nextName = nextManifest.name;
|
|
1542
|
+
const oldPath = pluginManifestPath(oldName);
|
|
1543
|
+
if (!fileExists(oldPath)) return res.status(404).json({ error: 'Plugin not found' });
|
|
1544
|
+
if (oldName !== nextName) {
|
|
1545
|
+
fs.rmSync(path.join(PLUGINS_ROOT, oldName), { recursive: true, force: true });
|
|
1546
|
+
}
|
|
1547
|
+
writePluginManifest(nextName, nextManifest);
|
|
1548
|
+
const marketplace = loadMarketplace();
|
|
1549
|
+
marketplace.plugins = (marketplace.plugins || []).filter(item => item.name !== oldName);
|
|
1550
|
+
marketplace.plugins.push({
|
|
1551
|
+
name: nextName,
|
|
1552
|
+
source: { source: 'local', path: `./plugins/${nextName}` },
|
|
1553
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
1554
|
+
category: req.body?.category || 'Custom'
|
|
1555
|
+
});
|
|
1556
|
+
writeMarketplace(marketplace);
|
|
1557
|
+
res.json({ ok: true, plugin: nextName });
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
res.status(400).json({ error: error.message });
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
app.delete('/api/plugins/:name', (req, res) => {
|
|
1564
|
+
try {
|
|
1565
|
+
const safeName = sanitizeSkillName(req.params.name);
|
|
1566
|
+
fs.rmSync(path.join(PLUGINS_ROOT, safeName), { recursive: true, force: true });
|
|
1567
|
+
const marketplace = loadMarketplace();
|
|
1568
|
+
marketplace.plugins = (marketplace.plugins || []).filter(item => item.name !== safeName);
|
|
1569
|
+
writeMarketplace(marketplace);
|
|
1570
|
+
res.json({ ok: true });
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
res.status(400).json({ error: error.message });
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
app.get('/api/sessions', (req, res) => {
|
|
1577
|
+
const projectPath = req.query.project || null;
|
|
1578
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
|
|
1579
|
+
const offset = parseInt(req.query.offset, 10) || 0;
|
|
1580
|
+
const sessions = listSessions(projectPath);
|
|
1581
|
+
|
|
1582
|
+
res.json({
|
|
1583
|
+
sessions: sessions.slice(offset, offset + limit),
|
|
1584
|
+
total: sessions.length,
|
|
1585
|
+
offset
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
app.get('/api/sessions/:id', (req, res) => {
|
|
1590
|
+
const detail = readSessionDetail(req.params.id);
|
|
1591
|
+
if (!detail) return res.status(404).json({ error: 'Session not found' });
|
|
1592
|
+
res.json(detail);
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
app.post('/api/sessions', (req, res) => {
|
|
1596
|
+
try {
|
|
1597
|
+
const title = String(req.body?.title || '').trim() || 'New session';
|
|
1598
|
+
const cwd = path.resolve(req.body?.cwd || '/Volumes/Projects');
|
|
1599
|
+
const modelProvider = String(req.body?.modelProvider || 'openai');
|
|
1600
|
+
const cliVersion = String(req.body?.cliVersion || '0.120.0');
|
|
1601
|
+
const id = randomUUID();
|
|
1602
|
+
const { filePath, timestamp } = createSessionFile({ id, cwd, title, modelProvider, cliVersion });
|
|
1603
|
+
|
|
1604
|
+
execSqlite(STATE_DB, `
|
|
1605
|
+
INSERT INTO threads (
|
|
1606
|
+
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
|
1607
|
+
sandbox_policy, approval_mode, cli_version, first_user_message
|
|
1608
|
+
) VALUES (
|
|
1609
|
+
${sqlString(id)},
|
|
1610
|
+
${sqlString(filePath)},
|
|
1611
|
+
${timestamp},
|
|
1612
|
+
${timestamp},
|
|
1613
|
+
'cli',
|
|
1614
|
+
${sqlString(modelProvider)},
|
|
1615
|
+
${sqlString(cwd)},
|
|
1616
|
+
${sqlString(title)},
|
|
1617
|
+
${sqlString('{"type":"danger-full-access","writable_roots":[],"network_access":true}')},
|
|
1618
|
+
'never',
|
|
1619
|
+
${sqlString(cliVersion)},
|
|
1620
|
+
${sqlString(title)}
|
|
1621
|
+
);
|
|
1622
|
+
`);
|
|
1623
|
+
|
|
1624
|
+
invalidateCache();
|
|
1625
|
+
res.json(readSessionDetail(id));
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
res.status(500).json({ error: error.message });
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
app.put('/api/sessions/:id', (req, res) => {
|
|
1632
|
+
const title = req.body?.title;
|
|
1633
|
+
if (!title) return res.status(400).json({ error: 'Missing title' });
|
|
1634
|
+
try {
|
|
1635
|
+
execSqlite(STATE_DB, `UPDATE threads SET title = ${sqlString(title)}, updated_at = strftime('%s','now') WHERE id = ${sqlString(req.params.id)};`);
|
|
1636
|
+
invalidateCache();
|
|
1637
|
+
res.json({ ok: true });
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
res.status(500).json({ error: error.message });
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
app.delete('/api/sessions/:id', (req, res) => {
|
|
1644
|
+
try {
|
|
1645
|
+
const filePath = findSessionFile(req.params.id);
|
|
1646
|
+
if (filePath) fs.rmSync(filePath, { force: true });
|
|
1647
|
+
execSqlite(STATE_DB, `DELETE FROM threads WHERE id = ${sqlString(req.params.id)};`);
|
|
1648
|
+
invalidateCache();
|
|
1649
|
+
res.json({ ok: true });
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
res.status(500).json({ error: error.message });
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
app.get('/api/history', (req, res) => {
|
|
1656
|
+
const projectPath = req.query.project ? path.resolve(req.query.project) : null;
|
|
1657
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 200, 500);
|
|
1658
|
+
const history = readHistoryEntries(limit * 4);
|
|
1659
|
+
|
|
1660
|
+
if (!projectPath) return res.json({ entries: history.slice(0, limit) });
|
|
1661
|
+
|
|
1662
|
+
const allowedIds = new Set(listSessions(projectPath).map(session => session.id));
|
|
1663
|
+
res.json({ entries: history.filter(entry => allowedIds.has(entry.sessionId)).slice(0, limit) });
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
app.get('/api/stats/tools', (req, res) => {
|
|
1667
|
+
const projectPath = req.query.project || null;
|
|
1668
|
+
const days = parseInt(req.query.days, 10) || 30;
|
|
1669
|
+
res.json(countToolUsage(projectPath, {
|
|
1670
|
+
days,
|
|
1671
|
+
from: req.query.from || null,
|
|
1672
|
+
to: req.query.to || null
|
|
1673
|
+
}));
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
app.get('/api/stats/usage', (req, res) => {
|
|
1677
|
+
const projectPath = req.query.project || null;
|
|
1678
|
+
const days = parseInt(req.query.days, 10) || 14;
|
|
1679
|
+
res.json(buildUsageStats(projectPath, {
|
|
1680
|
+
days,
|
|
1681
|
+
from: req.query.from || null,
|
|
1682
|
+
to: req.query.to || null
|
|
1683
|
+
}));
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
app.get('/api/events', (req, res) => {
|
|
1687
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1688
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1689
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1690
|
+
res.flushHeaders();
|
|
1691
|
+
|
|
1692
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`);
|
|
1693
|
+
sseClients.add(res);
|
|
1694
|
+
|
|
1695
|
+
req.on('close', () => sseClients.delete(res));
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
app.listen(PORT, () => {
|
|
1699
|
+
console.log('\n Codex Map');
|
|
1700
|
+
console.log(' ─────────');
|
|
1701
|
+
console.log(` http://localhost:${PORT}\n`);
|
|
1702
|
+
});
|