claude-code-session-manager 0.37.0 → 0.37.2

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 (38) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
  5. package/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
  9. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  10. package/scripts/lib/activeSessions.cjs +117 -0
  11. package/scripts/lib/watchdogHelpers.cjs +829 -0
  12. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  13. package/src/main/__tests__/docEdit.test.cjs +164 -2
  14. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  15. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  16. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  17. package/src/main/browserAgentServer.cjs +11 -10
  18. package/src/main/chatRunner.cjs +21 -2
  19. package/src/main/docEdit.cjs +125 -5
  20. package/src/main/health.cjs +15 -0
  21. package/src/main/index.cjs +12 -6
  22. package/src/main/ipcSchemas.cjs +14 -0
  23. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  24. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  25. package/src/main/lib/localAdminHttp.cjs +157 -0
  26. package/src/main/lib/personaImportHealth.cjs +161 -0
  27. package/src/main/lib/prdCreate.cjs +107 -17
  28. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  29. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  30. package/src/main/scheduler.cjs +198 -54
  31. package/src/main/templates/PRD_AUTHORING.md +4 -4
  32. package/src/main/transcripts.cjs +1 -85
  33. package/src/preload/api.d.ts +12 -1
  34. package/src/preload/index.cjs +6 -0
  35. package/dist/assets/index-CTTjT08J.css +0 -32
  36. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  37. package/src/main/__tests__/adminServer.test.cjs +0 -380
  38. package/src/main/adminServer.cjs +0 -242
@@ -6,6 +6,7 @@ const fsp = require('node:fs/promises');
6
6
  const os = require('node:os');
7
7
  const { schemas, validated } = require('./ipcSchemas.cjs');
8
8
  const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
+ const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
9
10
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
10
11
  const browserView = require('./browserView.cjs');
11
12
  const browserCapture = require('./browserCapture.cjs');
@@ -24,8 +25,11 @@ crashDiagnostics.startCrashReporter();
24
25
  const voiceHotkey = require('./voiceHotkey.cjs');
25
26
  const voiceWizard = require('./voiceWizard.cjs');
26
27
  const scheduler = require('./scheduler.cjs');
27
- const { createAdminServer } = require('./adminServer.cjs');
28
- const adminServer = createAdminServer(scheduler.remote);
28
+ const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
29
+ const prdCreate = require('./lib/prdCreate.cjs');
30
+ const adminHttp = createAdminHttp();
31
+ scheduler.registerAdminRoutes(adminHttp);
32
+ prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
29
33
  const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
30
34
  const browserAgentServer = createBrowserAgentServer({
31
35
  listTabs: () => browserView.listViews(),
@@ -51,7 +55,7 @@ const agentMemory = require('./agentMemory.cjs');
51
55
  const git = require('./git.cjs');
52
56
  const superagent = require('./superagent.cjs');
53
57
  const filesIpc = require('./files.cjs');
54
- const { registerDocEditHandlers } = require('./docEdit.cjs');
58
+ const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
55
59
  const searchIpc = require('./search.cjs');
56
60
  const repoAnalyzer = require('./repoAnalyzer.cjs');
57
61
  const hivesIpc = require('./hives.cjs');
@@ -304,6 +308,7 @@ async function rebootApp() {
304
308
  pluginInstall.attachWindow(mainWindow);
305
309
  superagent.attachWindow(mainWindow);
306
310
  chatRunner.attachWindow(mainWindow);
311
+ attachDocEditWindow(mainWindow);
307
312
  rebooting = false;
308
313
  return;
309
314
  }
@@ -841,7 +846,7 @@ const isDev = process.env.SM_DEV === '1' || process.env.SM_E2E === '1';
841
846
  if (!isDev) {
842
847
  const gotLock = app.requestSingleInstanceLock();
843
848
  if (!gotLock) {
844
- app.quit();
849
+ terminateLosingInstance(app);
845
850
  } else {
846
851
  app.on('second-instance', () => {
847
852
  if (mainWindow && !mainWindow.isDestroyed()) {
@@ -1080,10 +1085,11 @@ app.whenReady().then(async () => {
1080
1085
  superagent.attachWindow(mainWindow);
1081
1086
  webRemote.attachWindow(mainWindow);
1082
1087
  chatRunner.attachWindow(mainWindow);
1088
+ attachDocEditWindow(mainWindow);
1083
1089
  scheduler.init().catch((e) => {
1084
1090
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
1085
1091
  });
1086
- adminServer.start().catch((e) => {
1092
+ adminHttp.start().catch((e) => {
1087
1093
  logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1088
1094
  });
1089
1095
  browserAgentServer.start().catch((e) => {
@@ -1194,7 +1200,7 @@ app.on('before-quit', () => {
1194
1200
  configMgr.closeAllWatchers();
1195
1201
  transcripts.closeAll();
1196
1202
  watchers.manager.killAll();
1197
- adminServer.stop().catch(() => {});
1203
+ adminHttp.stop().catch(() => {});
1198
1204
  browserAgentServer.stop().catch(() => {});
1199
1205
  // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
1200
1206
  // ceiling so a wedged exporter can't hold quit.
@@ -420,6 +420,19 @@ const docEditRun = z.object({
420
420
  path: z.string().min(1).max(4096),
421
421
  before: z.string().min(1).max(8000),
422
422
  instruction: z.string().min(1).max(2000),
423
+ // 60000 is the intended document-context budget; the extra headroom
424
+ // accommodates truncateDocumentText's head+tail+marker overhead (~60041
425
+ // chars worst case) so an already-truncated payload never gets rejected.
426
+ documentText: z.string().max(60100).optional(),
427
+ }).strict();
428
+
429
+ // docedit:run-in-session — consumed by docEdit.cjs's docEditViaSession (PRD 680: route a doc
430
+ // edit into an already-open, currently-idle chat session instead of an isolated claude -p).
431
+ const docEditRunInSession = docEditRun.omit({ path: true }).extend({
432
+ tabId: z.string().min(1).max(128),
433
+ sessionId: z.string().min(1).max(128),
434
+ cwd: z.string().min(1).max(4096),
435
+ requestId: z.string().min(1).max(128),
423
436
  }).strict();
424
437
 
425
438
  // ──────────────────────────────────────────── Chat runner (PRD 319)
@@ -742,6 +755,7 @@ module.exports = {
742
755
  watchersKillTab,
743
756
  filesDuplicate,
744
757
  docEditRun,
758
+ docEditRunInSession,
745
759
  chatRun,
746
760
  chatCancel,
747
761
  chatProbeContext,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * localAdminHttp.test.cjs — unit tests for the generic loopback-only HTTP
3
+ * transport (PRD 689 — extracted from the former standalone admin HTTP server
4
+ * module). Covers
5
+ * only transport concerns: auth rejection, token persistence, route dispatch,
6
+ * and 404 on unknown routes. Route *logic* tests live beside their owning
7
+ * module (scheduler.cjs's admin routes, prdCreate.cjs's create-prd route).
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/localAdminHttp.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const http = require('node:http');
16
+ const fs = require('node:fs');
17
+ const { createAdminHttp, TOKEN_PATH } = require('../localAdminHttp.cjs');
18
+
19
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
20
+ return new Promise((resolve, reject) => {
21
+ const headers = {};
22
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
23
+ let payload;
24
+ if (body !== undefined) {
25
+ payload = JSON.stringify(body);
26
+ headers['Content-Type'] = 'application/json';
27
+ headers['Content-Length'] = Buffer.byteLength(payload);
28
+ }
29
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
30
+ const chunks = [];
31
+ res.on('data', (c) => chunks.push(c));
32
+ res.on('end', () => {
33
+ const text = Buffer.concat(chunks).toString('utf8');
34
+ let json = null;
35
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
36
+ resolve({ status: res.statusCode, json, text });
37
+ });
38
+ });
39
+ req.on('error', reject);
40
+ if (payload) req.write(payload);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ test('request without bearer token returns 401', async () => {
46
+ const admin = createAdminHttp();
47
+ admin.registerRoute('GET', '/ping', async (req, res) => { res.writeHead(200); res.end('{}'); });
48
+ const { port } = await admin.start();
49
+ try {
50
+ const res = await request(port, { path: '/ping' });
51
+ expect(res.status).toBe(401);
52
+ expect(res.json.ok).toBe(false);
53
+ } finally {
54
+ await admin.stop();
55
+ }
56
+ });
57
+
58
+ test('request with wrong bearer token returns 401', async () => {
59
+ const admin = createAdminHttp();
60
+ admin.registerRoute('GET', '/ping', async (req, res) => { res.writeHead(200); res.end('{}'); });
61
+ const { port } = await admin.start();
62
+ try {
63
+ const res = await request(port, { path: '/ping', token: 'not-the-token' });
64
+ expect(res.status).toBe(401);
65
+ } finally {
66
+ await admin.stop();
67
+ }
68
+ });
69
+
70
+ test('registered route with correct token is dispatched', async () => {
71
+ const admin = createAdminHttp();
72
+ admin.registerRoute('GET', '/ping', async (req, res) => {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ res.end(JSON.stringify({ ok: true, pong: true }));
75
+ });
76
+ const { port, token } = await admin.start();
77
+ try {
78
+ const res = await request(port, { path: '/ping', token });
79
+ expect(res.status).toBe(200);
80
+ expect(res.json).toEqual({ ok: true, pong: true });
81
+ } finally {
82
+ await admin.stop();
83
+ }
84
+ });
85
+
86
+ test('unregistered path/method returns 404', async () => {
87
+ const admin = createAdminHttp();
88
+ const { port, token } = await admin.start();
89
+ try {
90
+ const res = await request(port, { path: '/nope', token });
91
+ expect(res.status).toBe(404);
92
+ } finally {
93
+ await admin.stop();
94
+ }
95
+ });
96
+
97
+ test('server binds only to 127.0.0.1', async () => {
98
+ const admin = createAdminHttp();
99
+ await admin.start();
100
+ try {
101
+ expect(admin.server.address().address).toBe('127.0.0.1');
102
+ } finally {
103
+ await admin.stop();
104
+ }
105
+ });
106
+
107
+ test('start() persists token file with port + token, mode 0600', async () => {
108
+ const admin = createAdminHttp();
109
+ const { port, token } = await admin.start();
110
+ try {
111
+ const raw = fs.readFileSync(TOKEN_PATH, 'utf8');
112
+ const parsed = JSON.parse(raw);
113
+ expect(parsed.port).toBe(port);
114
+ expect(parsed.token).toBe(token);
115
+ const mode = fs.statSync(TOKEN_PATH).mode & 0o777;
116
+ expect(mode).toBe(0o600);
117
+ } finally {
118
+ await admin.stop();
119
+ }
120
+ });
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const MAX_RAW_STR = 4096;
4
+
5
+ // Block types whose text/content fields are parsed structurally by
6
+ // orchestrator.ts / race.ts — truncating them produces mid-token "…" and
7
+ // unparseable JSON, so they are exempt from the size cap.
8
+ const EXEMPT_TYPES = new Set(['tool_result', 'tool_use']);
9
+
10
+ /**
11
+ * Cap string fields in a content block array so arbitrary tool output doesn't
12
+ * bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are passed
13
+ * through intact so that structured result payloads survive to the digest
14
+ * parsers in race.ts / orchestrator.ts.
15
+ */
16
+ function trimContentArray(content) {
17
+ if (!Array.isArray(content)) return content;
18
+ return content.map((block) => {
19
+ if (!block || typeof block !== 'object') return block;
20
+ if (EXEMPT_TYPES.has(block.type)) return block;
21
+ const b = { ...block };
22
+ if (typeof b.text === 'string' && b.text.length > MAX_RAW_STR) {
23
+ b.text = b.text.slice(0, MAX_RAW_STR) + '…';
24
+ }
25
+ if (typeof b.content === 'string' && b.content.length > MAX_RAW_STR) {
26
+ b.content = b.content.slice(0, MAX_RAW_STR) + '…';
27
+ }
28
+ if (Array.isArray(b.content)) {
29
+ b.content = trimContentArray(b.content);
30
+ }
31
+ return b;
32
+ });
33
+ }
34
+
35
+ /** Build the slim raw projection used by race.ts and orchestrator.ts. */
36
+ function makeRaw(obj) {
37
+ const msgContent = obj?.message?.content;
38
+ return { message: { content: trimContentArray(msgContent) } };
39
+ }
40
+
41
+ /**
42
+ * Parse one JSONL line defensively. Real schema drifts, so we pass through
43
+ * anything that parses and tag a coarse `kind`.
44
+ */
45
+ function classifyLine(obj) {
46
+ if (!obj || typeof obj !== 'object') return null;
47
+ // Many shapes exist — try several common fields.
48
+ const type = obj.type || obj.event || obj.role;
49
+ const msg = obj.message || obj;
50
+ const content = msg?.content;
51
+
52
+ // Usage rollups arrive as summary events.
53
+ if (obj.usage || msg?.usage) {
54
+ return { kind: 'usage', data: obj.usage || msg.usage, raw: makeRaw(obj) };
55
+ }
56
+
57
+ // Tool uses: scan content array for tool_use blocks.
58
+ if (Array.isArray(content)) {
59
+ for (const block of content) {
60
+ if (block?.type === 'tool_use') {
61
+ if (block.name === 'TodoWrite') {
62
+ return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
63
+ }
64
+ if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
65
+ return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
66
+ }
67
+ if (block.name === 'Agent' || block.name === 'Task') {
68
+ // Include block.id as toolUseId so the live store can match the
69
+ // corresponding tool_result and update per-agent lastActivityAt.
70
+ return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
71
+ }
72
+ return {
73
+ kind: 'tool_use',
74
+ data: { name: block.name, input: block.input, id: block.id },
75
+ raw: makeRaw(obj),
76
+ };
77
+ }
78
+ // tool_result carries the tool_use_id of the completed Task/Agent call.
79
+ // The live store uses this to update the agent's lastActivityAt bookend.
80
+ if (block?.type === 'tool_result' && block.tool_use_id) {
81
+ return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
82
+ }
83
+ }
84
+ }
85
+
86
+ return { kind: type || 'message', data: obj, raw: makeRaw(obj) };
87
+ }
88
+
89
+ module.exports = { MAX_RAW_STR, EXEMPT_TYPES, trimContentArray, makeRaw, classifyLine };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * localAdminHttp.cjs — generic loopback-only HTTP transport with bearer-token
3
+ * auth, extracted from the former standalone admin HTTP server module
4
+ * (PRD 689). This module owns
5
+ * ONLY the transport: token bootstrap/persistence, timing-safe comparison,
6
+ * body reading, JSON responses, and a route-registration/dispatch mechanism.
7
+ * Route *logic* (job-management, PRD creation) lives beside the module that
8
+ * already owns that capability — see scheduler.cjs's registerAdminRoutes and
9
+ * prdCreate.cjs's registerAdminRoute.
10
+ *
11
+ * Security posture (unchanged from the former module):
12
+ * - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
13
+ * off-box.
14
+ * - Bearer token, regenerated every app boot, written to
15
+ * ~/.claude/session-manager/admin-api.json with 0600 perms.
16
+ * - Token compared with crypto.timingSafeEqual to avoid a timing
17
+ * side-channel on the comparison itself.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const http = require('node:http');
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const fsp = require('node:fs/promises');
27
+ const config = require('../config.cjs');
28
+
29
+ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
30
+
31
+ function timingSafeEqualStrings(a, b) {
32
+ const bufA = Buffer.from(String(a ?? ''));
33
+ const bufB = Buffer.from(String(b ?? ''));
34
+ if (bufA.length !== bufB.length) {
35
+ // Compare against a same-length buffer anyway so the failure path still
36
+ // takes constant time relative to the (fixed) token length, rather than
37
+ // returning early on a length mismatch.
38
+ crypto.timingSafeEqual(bufA, bufA);
39
+ return false;
40
+ }
41
+ return crypto.timingSafeEqual(bufA, bufB);
42
+ }
43
+
44
+ function readBody(req, maxBytes = 1024 * 1024) {
45
+ return new Promise((resolve, reject) => {
46
+ let total = 0;
47
+ const chunks = [];
48
+ req.on('data', (chunk) => {
49
+ total += chunk.length;
50
+ if (total > maxBytes) {
51
+ reject(new Error('body too large'));
52
+ req.destroy();
53
+ return;
54
+ }
55
+ chunks.push(chunk);
56
+ });
57
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
58
+ req.on('error', reject);
59
+ });
60
+ }
61
+
62
+ function sendJson(res, status, obj) {
63
+ const body = JSON.stringify(obj);
64
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
65
+ res.end(body);
66
+ }
67
+
68
+ /**
69
+ * Create the admin HTTP transport. Route handlers are registered via
70
+ * `registerRoute(method, url, handler)`, where `handler` is
71
+ * `async (req, res, rawBody) => void` — raw body reading is the transport's
72
+ * job (routes call `JSON.parse` themselves as needed, matching the original
73
+ * per-route body handling in the former module).
74
+ */
75
+ function createAdminHttp() {
76
+ let server = null;
77
+ let token = null;
78
+ const routes = new Map();
79
+
80
+ async function ensureToken() {
81
+ token = crypto.randomBytes(32).toString('hex');
82
+ await config.writeJson(TOKEN_PATH, { port: null, token });
83
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
84
+ return token;
85
+ }
86
+
87
+ async function persistPort(port) {
88
+ await config.writeJson(TOKEN_PATH, { port, token });
89
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
90
+ }
91
+
92
+ function authorized(req) {
93
+ const header = req.headers.authorization || '';
94
+ const match = /^Bearer (.+)$/.exec(header);
95
+ if (!match) return false;
96
+ return timingSafeEqualStrings(match[1], token);
97
+ }
98
+
99
+ function registerRoute(method, url, handler) {
100
+ routes.set(`${method} ${url}`, handler);
101
+ }
102
+
103
+ async function handleRequest(req, res) {
104
+ if (!authorized(req)) {
105
+ sendJson(res, 401, { ok: false, error: 'unauthorized' });
106
+ return;
107
+ }
108
+ try {
109
+ const handler = routes.get(`${req.method} ${req.url}`);
110
+ if (!handler) {
111
+ sendJson(res, 404, { ok: false, error: 'not found' });
112
+ return;
113
+ }
114
+ await handler(req, res);
115
+ } catch (e) {
116
+ sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
117
+ }
118
+ }
119
+
120
+ async function start() {
121
+ await ensureToken();
122
+ server = http.createServer((req, res) => {
123
+ handleRequest(req, res).catch(() => {
124
+ try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
125
+ });
126
+ });
127
+ await new Promise((resolve, reject) => {
128
+ server.once('error', reject);
129
+ server.listen(0, '127.0.0.1', resolve);
130
+ });
131
+ const { port } = server.address();
132
+ await persistPort(port);
133
+ return { port, token };
134
+ }
135
+
136
+ async function stop() {
137
+ if (!server) return;
138
+ await new Promise((resolve) => server.close(() => resolve()));
139
+ server = null;
140
+ }
141
+
142
+ return {
143
+ start,
144
+ stop,
145
+ registerRoute,
146
+ get token() { return token; },
147
+ get server() { return server; },
148
+ };
149
+ }
150
+
151
+ module.exports = {
152
+ createAdminHttp,
153
+ TOKEN_PATH,
154
+ timingSafeEqualStrings,
155
+ readBody,
156
+ sendJson,
157
+ };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * personaImportHealth.cjs — integrity check for Claude Code's `@path` import
5
+ * syntax in ~/.claude/CLAUDE.md.
6
+ *
7
+ * The `@path` import resolves a missing/moved target to nothing with zero
8
+ * warning — a renamed or deleted persona file silently drops that entire
9
+ * block of instructions from every session. This module parses the import
10
+ * chain (recursively, since imported files can themselves import further
11
+ * files) and asserts every resolved target exists and is non-empty, plus
12
+ * reports git ahead/behind for each unique repo the imports live in.
13
+ *
14
+ * Pure functions taking explicit inputs (a root file path), following the
15
+ * evaluateTickLiveness/readFreshHeartbeat pattern in health.cjs so this is
16
+ * independently unit-testable without touching the real home directory.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+
24
+ const MAX_IMPORT_DEPTH = 5;
25
+ // Matches a line consisting of `@` followed by an absolute or ~-relative path,
26
+ // e.g. "@/home/user/foo.md" or "@~/Projects/Agents/shared/core.md".
27
+ const IMPORT_LINE_RE = /^@(~?\/\S+)\s*$/;
28
+
29
+ function resolveImportPath(rawPath) {
30
+ if (rawPath.startsWith('~/')) return path.join(os.homedir(), rawPath.slice(2));
31
+ if (rawPath === '~') return os.homedir();
32
+ return rawPath;
33
+ }
34
+
35
+ // Extracts every `@<path>` import reference from a file's raw text content.
36
+ function extractImportPaths(content) {
37
+ const paths = [];
38
+ for (const line of content.split('\n')) {
39
+ const match = IMPORT_LINE_RE.exec(line.trim());
40
+ if (match) paths.push(resolveImportPath(match[1]));
41
+ }
42
+ return paths;
43
+ }
44
+
45
+ // Recursively walks the import chain starting at rootPath, collecting a
46
+ // result per resolved import: { importPath, exists, nonEmpty, ok }.
47
+ // Depth-capped to guard against a cyclic-import pathological case. Visited
48
+ // paths are deduped so a diamond-shaped import graph doesn't get reported
49
+ // twice or blow the depth budget.
50
+ function walkImports(rootPath, depth = MAX_IMPORT_DEPTH, seen = new Set()) {
51
+ const results = [];
52
+ if (depth <= 0) return results;
53
+ if (!fs.existsSync(rootPath)) return results;
54
+
55
+ let content;
56
+ try {
57
+ content = fs.readFileSync(rootPath, 'utf8');
58
+ } catch {
59
+ return results;
60
+ }
61
+
62
+ for (const importPath of extractImportPaths(content)) {
63
+ if (seen.has(importPath)) continue;
64
+ seen.add(importPath);
65
+
66
+ let exists = false;
67
+ let nonEmpty = false;
68
+ try {
69
+ const stat = fs.statSync(importPath);
70
+ exists = true;
71
+ nonEmpty = stat.size > 0;
72
+ } catch {
73
+ exists = false;
74
+ }
75
+
76
+ results.push({ importPath, exists, nonEmpty, ok: exists && nonEmpty });
77
+
78
+ if (exists && nonEmpty) {
79
+ results.push(...walkImports(importPath, depth - 1, seen));
80
+ }
81
+ }
82
+
83
+ return results;
84
+ }
85
+
86
+ // Walks upward from startDir looking for a `.git` directory, bounded at
87
+ // os.homedir() or the filesystem root.
88
+ function findGitRepoRoot(startDir) {
89
+ const home = os.homedir();
90
+ let dir = startDir;
91
+ for (;;) {
92
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
93
+ if (dir === home) return null;
94
+ const parent = path.dirname(dir);
95
+ if (parent === dir) return null;
96
+ dir = parent;
97
+ }
98
+ }
99
+
100
+ // Reports ahead/behind vs upstream for a git repo, without ever fetching
101
+ // (rev-list reports against whatever the local remote-tracking ref already
102
+ // has cached). Non-fatal on any failure — no upstream configured, detached
103
+ // HEAD, git not installed — annotated as a caveat, never thrown.
104
+ function checkRepoAheadBehind(repoRoot) {
105
+ try {
106
+ const out = execFileSync(
107
+ 'git',
108
+ ['-C', repoRoot, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'],
109
+ { encoding: 'utf8', timeout: 5000 }
110
+ ).trim();
111
+ const [ahead, behind] = out.split(/\s+/).map(Number);
112
+ return { repoRoot, ok: true, ahead, behind };
113
+ } catch (e) {
114
+ return {
115
+ repoRoot,
116
+ ok: false,
117
+ noUpstream: true,
118
+ error: (e.message || String(e)).split('\n')[0],
119
+ };
120
+ }
121
+ }
122
+
123
+ // Top-level entry point: parses ~/.claude/CLAUDE.md's import chain, checks
124
+ // every resolved target, and reports ahead/behind for each unique repo those
125
+ // imports live in. claudeMdPath is injectable for testing.
126
+ function checkPersonaImports(claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
127
+ if (!fs.existsSync(claudeMdPath)) {
128
+ return { ok: true, path: claudeMdPath, exists: false, imports: [], repos: [] };
129
+ }
130
+
131
+ const imports = walkImports(claudeMdPath);
132
+ const broken = imports.filter((i) => !i.ok);
133
+
134
+ const repoRoots = new Set();
135
+ for (const imp of imports) {
136
+ if (!imp.ok) continue;
137
+ const repoRoot = findGitRepoRoot(path.dirname(imp.importPath));
138
+ if (repoRoot) repoRoots.add(repoRoot);
139
+ }
140
+
141
+ const repos = [...repoRoots].map(checkRepoAheadBehind);
142
+
143
+ return {
144
+ ok: broken.length === 0,
145
+ path: claudeMdPath,
146
+ exists: true,
147
+ imports,
148
+ brokenImports: broken,
149
+ repos,
150
+ };
151
+ }
152
+
153
+ module.exports = {
154
+ checkPersonaImports,
155
+ walkImports,
156
+ extractImportPaths,
157
+ resolveImportPath,
158
+ findGitRepoRoot,
159
+ checkRepoAheadBehind,
160
+ MAX_IMPORT_DEPTH,
161
+ };