noctrace 0.5.1 → 0.6.1

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/README.md CHANGED
@@ -54,7 +54,10 @@ Requires Node.js 20+. That's it. No config required. Optional hooks for real-tim
54
54
  - **Token drift detection** — tracks how per-turn token cost drifts from baseline, warns when sessions burn excessive quota
55
55
  - **Context Health grade** — A-F letter grade from 5 signals with actionable recommendations
56
56
  - **Compact stats pill** — toolbar shows agent count, health grade, drift factor, total tokens, and session duration at a glance
57
- - **Filter with highlighting** — search by tool name, label, or keywords (`error`, `agent`, `running`) with yellow match highlighting
57
+ - **Advanced filtering** — structured filter syntax: `type:bash`, `>5s`, `<100ms`, `tokens:>1k`, `error`, `running`, `success` — combinable with plain text search
58
+ - **Per-tool latency stats** — Session Stats flyout shows P50/P95/Max latency per tool type; calls exceeding a configurable threshold (default 5s) are flagged with a clock icon
59
+ - **Loop detection** — flags 3+ consecutive identical tool calls (same tool + same input) as a warning tip on the row
60
+ - **Session comparison** — split-screen view comparing two sessions side-by-side: health grades, summary metrics, tool mix bars, and context fill trajectory sparklines
58
61
  - **Virtual scrolling** — handles sessions with thousands of tool calls
59
62
  - **Zoom & pan** — mouse wheel zoom (1-50x), click-drag pan
60
63
  - **Detail panel** — click any row for full tool input/output, resizable
@@ -66,6 +69,7 @@ Requires Node.js 20+. That's it. No config required. Optional hooks for real-tim
66
69
  - **Session export** — share sessions as standalone offline HTML files
67
70
  - **Hooks integration** — optional real-time event streaming from Claude Code
68
71
  - **Context Drift Rate** — detect accelerating token growth before context rot hits
72
+ - **MCP session registry** — when integrated with Claude Code, sessions self-register on start and unregister on exit; dashboard shows only active sessions with a live count indicator
69
73
 
70
74
  ![Noctrace waterfall timeline](docs/screenshots/noctrace-waterfall.gif)
71
75
 
@@ -95,6 +99,14 @@ Click any row to inspect the full tool input and output. Two-column layout shows
95
99
 
96
100
  ## How it works
97
101
 
102
+ Noctrace has two modes depending on how you start it:
103
+
104
+ **Standalone mode** (`npx noctrace`) — scans all of `~/.claude/projects/` and shows every session. Good for reviewing past work or running alongside a session you've already started.
105
+
106
+ **MCP mode** (via Claude Code integration) — sessions register and unregister themselves automatically. The session picker shows only currently active sessions and a "MCP mode — N active sessions" indicator. Multiple Claude Code sessions share one noctrace dashboard without interference.
107
+
108
+ In both modes:
109
+
98
110
  1. Starts a local server on `http://localhost:4117` (auto-finds next available port)
99
111
  2. Opens your browser
100
112
  3. Reads JSONL session logs from `~/.claude/projects/`
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared helpers for reading/writing Claude Code MCP server registration
4
+ * in ~/.claude/settings.json.
5
+ *
6
+ * Used by:
7
+ * - noctrace --enable / --disable
8
+ * - bin/postinstall.js
9
+ * - bin/preuninstall.js
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+
15
+ /**
16
+ * Resolve the path to ~/.claude/settings.json.
17
+ * Respects the CLAUDE_HOME env var override.
18
+ */
19
+ export function settingsPath() {
20
+ const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
21
+ return path.join(claudeHome, 'settings.json');
22
+ }
23
+
24
+ /**
25
+ * Resolve the ~/.claude directory path.
26
+ * Respects the CLAUDE_HOME env var override.
27
+ */
28
+ export function claudeDir() {
29
+ return process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
30
+ }
31
+
32
+ /**
33
+ * Read and parse settings.json.
34
+ * Returns {} if the file doesn't exist or contains invalid JSON.
35
+ */
36
+ export async function readSettings(filePath) {
37
+ try {
38
+ const raw = await fs.readFile(filePath, 'utf8');
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch {
42
+ // Corrupted JSON — start fresh rather than aborting
43
+ return {};
44
+ }
45
+ } catch (err) {
46
+ if (err.code === 'ENOENT') return {};
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Write settings back to disk, creating parent directories as needed.
53
+ */
54
+ export async function writeSettings(filePath, settings) {
55
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
56
+ await fs.writeFile(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
57
+ }
58
+
59
+ /**
60
+ * Register noctrace as an MCP server in ~/.claude/settings.json.
61
+ *
62
+ * Adds:
63
+ * mcpServers.noctrace = { command: "npx", args: ["-y", "noctrace", "--mcp"] }
64
+ *
65
+ * Preserves all other settings and MCP server entries.
66
+ *
67
+ * @param {object} [opts]
68
+ * @param {boolean} [opts.silent] Suppress console output (for postinstall quiet mode)
69
+ * @returns {{ alreadyConfigured: boolean }}
70
+ */
71
+ export async function enableMcp({ silent = false } = {}) {
72
+ const sp = settingsPath();
73
+ const settings = await readSettings(sp);
74
+
75
+ if (!settings.mcpServers) settings.mcpServers = {};
76
+
77
+ const already =
78
+ settings.mcpServers.noctrace != null &&
79
+ settings.mcpServers.noctrace.command === 'npx' &&
80
+ Array.isArray(settings.mcpServers.noctrace.args) &&
81
+ settings.mcpServers.noctrace.args.includes('--mcp');
82
+
83
+ settings.mcpServers.noctrace = {
84
+ command: 'npx',
85
+ args: ['-y', 'noctrace', '--mcp'],
86
+ };
87
+
88
+ await writeSettings(sp, settings);
89
+
90
+ if (!silent) {
91
+ if (already) {
92
+ console.log('[noctrace] MCP server already configured — updated entry.');
93
+ } else {
94
+ console.log('[noctrace] Registered noctrace MCP server in Claude Code settings.');
95
+ }
96
+ console.log('[noctrace] Settings written to:', sp);
97
+ }
98
+
99
+ return { alreadyConfigured: already };
100
+ }
101
+
102
+ /**
103
+ * Remove the noctrace MCP server entry from ~/.claude/settings.json.
104
+ *
105
+ * Cleans up the mcpServers key entirely if it becomes empty.
106
+ *
107
+ * @param {object} [opts]
108
+ * @param {boolean} [opts.silent] Suppress console output
109
+ * @returns {{ wasConfigured: boolean }}
110
+ */
111
+ export async function disableMcp({ silent = false } = {}) {
112
+ const sp = settingsPath();
113
+ const settings = await readSettings(sp);
114
+
115
+ const wasConfigured = settings.mcpServers?.noctrace != null;
116
+
117
+ if (!wasConfigured) {
118
+ if (!silent) {
119
+ console.log('[noctrace] Noctrace is not configured in Claude Code.');
120
+ }
121
+ return { wasConfigured: false };
122
+ }
123
+
124
+ delete settings.mcpServers.noctrace;
125
+
126
+ // Remove the mcpServers key entirely if it's now empty
127
+ if (Object.keys(settings.mcpServers).length === 0) {
128
+ delete settings.mcpServers;
129
+ }
130
+
131
+ await writeSettings(sp, settings);
132
+
133
+ if (!silent) {
134
+ console.log('[noctrace] Removed noctrace MCP server from Claude Code settings.');
135
+ console.log('[noctrace] Settings written to:', sp);
136
+ }
137
+
138
+ return { wasConfigured: true };
139
+ }
@@ -1,44 +1,199 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Minimal MCP server wrapper for the noctrace plugin.
3
+ * MCP server wrapper for the noctrace plugin.
4
4
  *
5
- * Starts the noctrace Express server as a side effect and speaks just enough
6
- * MCP (JSON-RPC 2.0 over stdio) to stay alive as a Claude Code managed process.
7
- * Exposes a single `open_dashboard` tool so Claude can tell the user the URL.
5
+ * Behaviour:
6
+ * 1. Discovers the current Claude Code session's JSONL path from env vars.
7
+ * 2. Checks whether noctrace is already running on port 4117.
8
+ * 3. If not running, starts the Express server and opens the browser.
9
+ * 4. Registers this session via POST /api/sessions/register.
10
+ * 5. Speaks JSON-RPC 2.0 over stdio to satisfy Claude Code's MCP protocol.
11
+ * 6. On exit (SIGTERM / SIGINT / stdin close), unregisters the session.
12
+ *
13
+ * Session path discovery order:
14
+ * a. CLAUDE_SESSION_PATH env var (direct path to the .jsonl file)
15
+ * b. CLAUDE_PROJECT_DIR or PWD → compute project slug → find newest .jsonl
16
+ * c. Fall back to null (register with null; file watcher will pick it up)
8
17
  */
9
18
  import { createInterface } from 'node:readline';
19
+ import fs from 'node:fs/promises';
20
+ import path from 'node:path';
21
+ import os from 'node:os';
22
+
23
+ const VERSION = '0.5.1';
24
+ const NOCTRACE_PORT = 4117;
25
+ const BASE_URL = `http://localhost:${NOCTRACE_PORT}`;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Session path discovery
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Convert an absolute filesystem path to the Claude project slug format.
33
+ * Slugs replace every "/" with "-" and strip any leading "-".
34
+ * Example: /Users/lam/dev/noctrace → -Users-lam-dev-noctrace
35
+ */
36
+ function pathToSlug(absPath) {
37
+ return absPath.replace(/\//g, '-');
38
+ }
39
+
40
+ /**
41
+ * Return the path to the most recently modified .jsonl file in `dir`,
42
+ * or null if the directory is empty / inaccessible.
43
+ *
44
+ * @param {string} dir — absolute path to a project directory
45
+ * @returns {Promise<string|null>}
46
+ */
47
+ async function newestJsonl(dir) {
48
+ let files;
49
+ try {
50
+ files = await fs.readdir(dir);
51
+ } catch {
52
+ return null;
53
+ }
54
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
55
+ if (jsonlFiles.length === 0) return null;
56
+
57
+ let newest = null;
58
+ let newestMtime = -Infinity;
59
+
60
+ for (const file of jsonlFiles) {
61
+ try {
62
+ const stat = await fs.stat(path.join(dir, file));
63
+ if (stat.mtimeMs > newestMtime) {
64
+ newestMtime = stat.mtimeMs;
65
+ newest = path.join(dir, file);
66
+ }
67
+ } catch {
68
+ // skip
69
+ }
70
+ }
71
+ return newest;
72
+ }
10
73
 
11
- const VERSION = '0.4.0';
12
- let serverPort = null;
13
- let browserOpened = false;
74
+ /**
75
+ * Discover the JSONL session path for the current Claude Code session.
76
+ *
77
+ * @returns {Promise<string|null>}
78
+ */
79
+ async function discoverSessionPath() {
80
+ // Option a: direct env var
81
+ if (process.env.CLAUDE_SESSION_PATH) {
82
+ return process.env.CLAUDE_SESSION_PATH;
83
+ }
84
+
85
+ // Option b: derive from project directory
86
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.env.PWD ?? null;
87
+ if (!projectDir) return null;
88
+
89
+ const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
90
+ const slug = pathToSlug(projectDir);
91
+ const projectSessionDir = path.join(claudeHome, 'projects', slug);
92
+
93
+ return newestJsonl(projectSessionDir);
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Port / server helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Check whether noctrace is already running on the configured port.
102
+ * Uses the /api/health endpoint; resolves true if reachable, false otherwise.
103
+ *
104
+ * @returns {Promise<boolean>}
105
+ */
106
+ async function isServerRunning() {
107
+ try {
108
+ const { default: http } = await import('node:http');
109
+ return await new Promise((resolve) => {
110
+ const req = http.get(`${BASE_URL}/api/health`, { timeout: 1500 }, (res) => {
111
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
112
+ });
113
+ req.on('error', () => resolve(false));
114
+ req.on('timeout', () => { req.destroy(); resolve(false); });
115
+ });
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
14
120
 
15
- // Start the Express server (lazy import to avoid loading before needed)
16
- async function boot() {
121
+ /**
122
+ * Start the noctrace Express server by importing the compiled entry point.
123
+ * Sets NOCTRACE_NO_AUTOSTART so the module does not start a second server
124
+ * instance when imported as a side-effect.
125
+ *
126
+ * @returns {Promise<void>}
127
+ */
128
+ async function startNoctraceServer() {
17
129
  process.env.NOCTRACE_NO_AUTOSTART = '1';
18
130
  const { startServer } = await import('../dist/server/server/index.js');
19
- serverPort = await startServer();
131
+ await startServer();
132
+ }
20
133
 
21
- // Open browser once on first start
22
- if (!browserOpened) {
23
- const open = (await import('open')).default;
24
- await open(`http://localhost:${serverPort}`);
25
- browserOpened = true;
26
- }
134
+ // ---------------------------------------------------------------------------
135
+ // Session registration
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * POST a session path to the noctrace register/unregister endpoint.
140
+ *
141
+ * @param {'register'|'unregister'} action
142
+ * @param {string} sessionPath
143
+ * @returns {Promise<void>}
144
+ */
145
+ async function postSessionAction(action, sessionPath) {
146
+ const { default: http } = await import('node:http');
147
+ const body = JSON.stringify({ sessionPath });
148
+ return new Promise((resolve) => {
149
+ const req = http.request(
150
+ {
151
+ hostname: 'localhost',
152
+ port: NOCTRACE_PORT,
153
+ path: `/api/sessions/${action}`,
154
+ method: 'POST',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ 'Content-Length': Buffer.byteLength(body),
158
+ },
159
+ timeout: 3000,
160
+ },
161
+ () => resolve(),
162
+ );
163
+ req.on('error', () => resolve());
164
+ req.on('timeout', () => { req.destroy(); resolve(); });
165
+ req.write(body);
166
+ req.end();
167
+ });
27
168
  }
28
169
 
29
- // JSON-RPC response helper
170
+ // ---------------------------------------------------------------------------
171
+ // JSON-RPC helpers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Write a JSON-RPC 2.0 response to stdout.
176
+ * All other output must go to stderr (stdout is the MCP channel).
177
+ *
178
+ * @param {unknown} id
179
+ * @param {unknown} result
180
+ */
30
181
  function respond(id, result) {
31
182
  const msg = JSON.stringify({ jsonrpc: '2.0', id, result });
32
183
  process.stdout.write(`${msg}\n`);
33
184
  }
34
185
 
35
- // Handle incoming JSON-RPC messages from Claude Code
186
+ /**
187
+ * Handle a single JSON-RPC message from Claude Code.
188
+ *
189
+ * @param {string} line
190
+ */
36
191
  function handleMessage(line) {
37
192
  let msg;
38
193
  try {
39
194
  msg = JSON.parse(line);
40
195
  } catch {
41
- return; // ignore malformed input
196
+ return;
42
197
  }
43
198
 
44
199
  const { id, method, params } = msg;
@@ -53,8 +208,7 @@ function handleMessage(line) {
53
208
  }
54
209
 
55
210
  if (method === 'notifications/initialized') {
56
- // No response needed for notifications
57
- return;
211
+ return; // no response for notifications
58
212
  }
59
213
 
60
214
  if (method === 'tools/list') {
@@ -73,15 +227,12 @@ function handleMessage(line) {
73
227
  if (method === 'tools/call') {
74
228
  const toolName = params?.name;
75
229
  if (toolName === 'open_dashboard') {
76
- const url = `http://localhost:${serverPort ?? 4117}`;
77
- // Open browser
78
- import('open').then((m) => m.default(url)).catch(() => {});
230
+ import('open').then((m) => m.default(BASE_URL)).catch(() => {});
79
231
  respond(id, {
80
- content: [{ type: 'text', text: `Noctrace dashboard: ${url}` }],
232
+ content: [{ type: 'text', text: `Noctrace dashboard: ${BASE_URL}` }],
81
233
  });
82
234
  return;
83
235
  }
84
- // Unknown tool
85
236
  respond(id, {
86
237
  content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
87
238
  isError: true,
@@ -95,20 +246,87 @@ function handleMessage(line) {
95
246
  }
96
247
  }
97
248
 
249
+ // ---------------------------------------------------------------------------
98
250
  // Main
251
+ // ---------------------------------------------------------------------------
252
+
99
253
  async function main() {
100
- await boot();
254
+ let sessionPath = await discoverSessionPath();
255
+
256
+ // Check if noctrace is already running; if not, start it (first MCP process wins).
257
+ // Use a retry loop to handle the race where two MCP processes start simultaneously.
258
+ const running = await isServerRunning();
259
+ if (!running) {
260
+ process.stderr.write('[noctrace-mcp] Starting noctrace server...\n');
261
+ try {
262
+ await startNoctraceServer();
263
+ } catch (err) {
264
+ if (err.code === 'EADDRINUSE') {
265
+ // Another process won the race — verify it's reachable before continuing.
266
+ const nowRunning = await isServerRunning();
267
+ if (!nowRunning) {
268
+ process.stderr.write('[noctrace-mcp] Fatal: port in use but server is not responding\n');
269
+ process.exit(1);
270
+ }
271
+ process.stderr.write('[noctrace-mcp] Server started by peer process — continuing\n');
272
+ } else {
273
+ // A real startup error (e.g. missing dist/, permission denied) — surface it.
274
+ process.stderr.write(`[noctrace-mcp] Fatal: could not start server: ${err.message}\n`);
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ // Open the browser — only the first MCP process to start the server does this
280
+ try {
281
+ const open = (await import('open')).default;
282
+ await open(BASE_URL);
283
+ } catch {
284
+ // Non-fatal — user can navigate manually
285
+ }
286
+ }
287
+
288
+ // Retry session discovery once — Claude Code may still be creating the session file
289
+ // when the MCP server starts. Wait 1 second and prefer the newer result.
290
+ await new Promise((r) => setTimeout(r, 1000));
291
+ const retryPath = await discoverSessionPath();
292
+ if (retryPath && retryPath !== sessionPath) {
293
+ sessionPath = retryPath;
294
+ }
101
295
 
296
+ if (sessionPath) {
297
+ process.stderr.write(`[noctrace-mcp] Session: ${sessionPath}\n`);
298
+ } else {
299
+ process.stderr.write('[noctrace-mcp] Could not discover session path — proceeding without registration\n');
300
+ }
301
+
302
+ // Register this session with the running noctrace server
303
+ if (sessionPath) {
304
+ await postSessionAction('register', sessionPath);
305
+ process.stderr.write('[noctrace-mcp] Session registered\n');
306
+ }
307
+
308
+ // Cleanup: unregister on process exit
309
+ let cleaned = false;
310
+ async function cleanup() {
311
+ if (cleaned) return;
312
+ cleaned = true;
313
+ if (sessionPath) {
314
+ await postSessionAction('unregister', sessionPath).catch(() => {});
315
+ }
316
+ }
317
+
318
+ process.on('SIGTERM', () => { void cleanup().then(() => process.exit(0)); });
319
+ process.on('SIGINT', () => { void cleanup().then(() => process.exit(0)); });
320
+
321
+ // Speak JSON-RPC over stdio for Claude Code's MCP protocol
102
322
  const rl = createInterface({ input: process.stdin });
103
323
  rl.on('line', handleMessage);
104
-
105
- // Keep alive until stdin closes (Claude Code manages our lifecycle)
106
324
  rl.on('close', () => {
107
- process.exit(0);
325
+ void cleanup().then(() => process.exit(0));
108
326
  });
109
327
  }
110
328
 
111
329
  main().catch((err) => {
112
- console.error('[noctrace-mcp] Fatal:', err.message);
330
+ process.stderr.write(`[noctrace-mcp] Fatal: ${err.message}\n`);
113
331
  process.exit(1);
114
332
  });
package/bin/noctrace.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
+ import { enableMcp, disableMcp } from './claude-config.js';
5
6
 
6
7
  const NOCTRACE_PORT = 4117;
7
8
  const NOCTRACE_BASE_URL = `http://localhost:${NOCTRACE_PORT}`;
@@ -177,6 +178,36 @@ if (args.includes('--uninstall-hooks')) {
177
178
  process.exit(0);
178
179
  }
179
180
 
181
+ if (args.includes('--enable')) {
182
+ const { alreadyConfigured } = await enableMcp();
183
+ if (alreadyConfigured) {
184
+ console.log('[noctrace] MCP server entry updated.');
185
+ } else {
186
+ console.log('[noctrace] ✓ Noctrace will auto-start with your next Claude Code session.');
187
+ console.log('[noctrace] Run "noctrace --disable" to remove.');
188
+ }
189
+ process.exit(0);
190
+ }
191
+
192
+ if (args.includes('--disable')) {
193
+ const { wasConfigured } = await disableMcp();
194
+ if (wasConfigured) {
195
+ console.log('[noctrace] ✓ Noctrace removed from Claude Code.');
196
+ } else {
197
+ console.log('[noctrace] Noctrace is not configured in Claude Code.');
198
+ }
199
+ process.exit(0);
200
+ }
201
+
202
+ if (args.includes('--mcp')) {
203
+ // MCP mode: boot the Express server and speak JSON-RPC over stdio.
204
+ // stdout is the JSON-RPC channel — all logging must go to stderr.
205
+ process.stderr.write('[noctrace-mcp] Starting MCP server...\n');
206
+ await import('./noctrace-mcp.js');
207
+ // noctrace-mcp.js takes over from here (runs main() internally)
208
+ process.exit(0); // unreachable — mcp keeps process alive via readline
209
+ }
210
+
180
211
  // Default: start the server and open the browser
181
212
  process.env.NOCTRACE_NO_AUTOSTART = '1';
182
213
  const { startServer } = await import('../dist/server/server/index.js');
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Auto-register noctrace as an MCP server in Claude Code settings.
4
+ * Runs after `npm install -g noctrace`.
5
+ *
6
+ * Only acts if ~/.claude/ already exists (i.e. Claude Code is installed).
7
+ * Never fails — always exits with code 0 so the install is never interrupted.
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import { claudeDir, enableMcp } from './claude-config.js';
11
+
12
+ async function run() {
13
+ // Check whether Claude Code is installed
14
+ try {
15
+ await fs.access(claudeDir());
16
+ } catch {
17
+ // ~/.claude doesn't exist — Claude Code is not installed, skip silently
18
+ return;
19
+ }
20
+
21
+ // Check if already configured
22
+ const settingsFile = (await import('./claude-config.js')).settingsPath();
23
+ let alreadyConfigured = false;
24
+ try {
25
+ const raw = await fs.readFile(settingsFile, 'utf8');
26
+ const settings = JSON.parse(raw);
27
+ alreadyConfigured = !!settings?.mcpServers?.noctrace;
28
+ } catch {
29
+ // No settings file or invalid JSON — not configured
30
+ }
31
+
32
+ if (alreadyConfigured) {
33
+ return; // Already set up, nothing to say
34
+ }
35
+
36
+ // Don't auto-modify config — just tell the user how to opt in
37
+ console.log('');
38
+ console.log(' \x1b[36mnoctrace\x1b[0m installed successfully.');
39
+ console.log('');
40
+ console.log(' Auto-start with Claude Code (recommended):');
41
+ console.log(' \x1b[1mnoctrace --enable\x1b[0m');
42
+ console.log('');
43
+ console.log(' Or run standalone:');
44
+ console.log(' \x1b[1mnoctrace\x1b[0m');
45
+ console.log('');
46
+ }
47
+
48
+ run().catch(() => {
49
+ // Silently swallow all errors — never break the install
50
+ }).finally(() => {
51
+ process.exit(0);
52
+ });
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Remove noctrace MCP server registration from Claude Code settings.
4
+ * Runs before `npm uninstall -g noctrace`.
5
+ *
6
+ * Never fails — always exits with code 0 so the uninstall is never interrupted.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import { claudeDir, disableMcp } from './claude-config.js';
10
+
11
+ async function run() {
12
+ // Check whether Claude Code is installed before touching anything
13
+ try {
14
+ await fs.access(claudeDir());
15
+ } catch {
16
+ // ~/.claude doesn't exist — nothing to clean up
17
+ return;
18
+ }
19
+
20
+ const { wasConfigured } = await disableMcp({ silent: true });
21
+
22
+ if (wasConfigured) {
23
+ console.log('[noctrace] Removed noctrace MCP server from Claude Code settings.');
24
+ }
25
+ // If not configured, exit quietly
26
+ }
27
+
28
+ run().catch(() => {
29
+ // Silently swallow all errors — never block the uninstall
30
+ }).finally(() => {
31
+ process.exit(0);
32
+ });
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-8{top:calc(var(--spacing) * 8)}.right-0{right:calc(var(--spacing) * 0)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.left-0{left:calc(var(--spacing) * 0)}.left-2{left:calc(var(--spacing) * 2)}.z-10{z-index:10}.z-50{z-index:50}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-full{height:100%}.w-full{width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.flex-col{flex-direction:column}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-sm{border-radius:var(--radius-sm)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-7{padding-left:calc(var(--spacing) * 7)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.uppercase{text-transform:uppercase}.italic{font-style:italic}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.\[file\:line\]{file:line}@media (width>=48rem){.md\:hidden{display:none}}}:root{--ctp-base:#1e1e2e;--ctp-mantle:#181825;--ctp-crust:#11111b;--ctp-surface0:#313244;--ctp-surface1:#45475a;--ctp-surface2:#585b70;--ctp-overlay0:#6c7086;--ctp-overlay1:#7f849c;--ctp-text:#cdd6f4;--ctp-subtext0:#a6adc8;--ctp-subtext1:#bac2de;--ctp-blue:#89b4fa;--ctp-green:#a6e3a1;--ctp-yellow:#f9e2af;--ctp-peach:#fab387;--ctp-mauve:#cba6f7;--ctp-teal:#94e2d5;--ctp-red:#f38ba8;--ctp-pink:#f5c2e7;--ctp-lavender:#b4befe;--color-read:var(--ctp-blue);--color-write:var(--ctp-green);--color-edit:var(--ctp-yellow);--color-bash:var(--ctp-peach);--color-agent:var(--ctp-mauve);--color-grep:var(--ctp-teal);--color-error:var(--ctp-red);--color-running:var(--ctp-pink)}body{background-color:var(--ctp-base);color:var(--ctp-text);font-family:SF Mono,Cascadia Code,JetBrains Mono,Fira Code,ui-monospace,monospace}@keyframes pulse-edge{0%,to{opacity:.6}50%{opacity:.1}}.running-pulse{animation:1.2s ease-in-out infinite pulse-edge}@keyframes noc-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.85)}}@keyframes noc-blink{0%,to{opacity:1}50%{opacity:0}}@media (width<=768px){.hidden-mobile{display:none!important}}.sidebar-panel{background-color:var(--ctp-mantle);flex-direction:column;flex-shrink:0;width:240px;display:flex;overflow:hidden}@media (width<=767px){.sidebar-panel{width:240px;transition:transform .2s;position:fixed;top:0;bottom:0;left:0;transform:translate(-100%)}.sidebar-panel[data-open=true]{transform:translate(0)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}