contextspin 0.1.2 → 0.3.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.
@@ -1,55 +1,12 @@
1
1
  {
2
2
  "sources": [
3
- {
4
- "type": "mcp",
5
- "tool": "slack_search_public",
6
- "args": {
7
- "query": "mentions:me is:unread"
8
- },
9
- "format": "Slack: {{ text }}",
10
- "label": "Slack",
11
- "cooldown": 300,
12
- "maxSnippets": 2
13
- },
14
3
  {
15
4
  "type": "cli",
16
- "command": "gh pr list --review-requested @me --json title,number --limit 3",
17
- "format": "PR #{{ number }} needs your review: {{ title }}",
18
- "label": "GitHub",
5
+ "command": "gh pr list --review-requested @me --json number,title --limit 5",
6
+ "format": "👀 review #{{ number }}: {{ title }}",
7
+ "label": "review",
19
8
  "cooldown": 120,
20
9
  "maxSnippets": 3
21
- },
22
- {
23
- "type": "cli",
24
- "command": "gh run list --json status,name,headBranch --limit 5",
25
- "filter": "{{ status }} == failure",
26
- "format": "CI failing: {{ name }} on {{ headBranch }}",
27
- "label": "CI",
28
- "cooldown": 60,
29
- "maxSnippets": 2
30
- },
31
- {
32
- "type": "mcp",
33
- "tool": "notion-search",
34
- "args": {
35
- "query": "assigned:me status:open"
36
- },
37
- "format": "Notion: {{ text }}",
38
- "label": "Notion",
39
- "cooldown": 300,
40
- "maxSnippets": 2
41
- },
42
- {
43
- "type": "http",
44
- "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
45
- "headers": {
46
- "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}"
47
- },
48
- "jq": ".results[0].value",
49
- "format": "Grafana: {{ value }}",
50
- "label": "Grafana",
51
- "cooldown": 30,
52
- "maxSnippets": 1
53
10
  }
54
11
  ],
55
12
  "injection": {
@@ -60,13 +17,6 @@
60
17
  "snippets": {
61
18
  "deduplication": true,
62
19
  "cooldownAfterShown": 3,
63
- "priorityOrder": [
64
- "incident",
65
- "ci",
66
- "slack",
67
- "calendar",
68
- "github",
69
- "jira"
70
- ]
20
+ "priorityOrder": ["review", "incident", "ci", "slack", "calendar", "github", "gitlab", "jira"]
71
21
  }
72
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Replace Claude Code spinner/statusline text with live org context (meetings, Slack, CI, incidents, PRs) aggregated from your existing MCP servers, CLIs, and HTTP endpoints.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -2,7 +2,6 @@
2
2
  // src/cli.js — Commander-based command-line interface for ContextSpin.
3
3
 
4
4
  import fs from 'node:fs';
5
- import fsp from 'node:fs/promises';
6
5
  import path from 'node:path';
7
6
  import process from 'node:process';
8
7
  import readline from 'node:readline/promises';
@@ -12,10 +11,13 @@ import { Command } from 'commander';
12
11
 
13
12
  import {
14
13
  CONFIG_PATH,
14
+ STATUSLINE_SH,
15
+ CLAUDE_SETTINGS_PATH,
15
16
  configExists,
16
17
  loadConfig,
17
18
  saveConfig,
18
19
  normalizeConfig,
20
+ defaultConfig,
19
21
  } from './config.js';
20
22
  import {
21
23
  startDaemonDetached,
@@ -25,6 +27,7 @@ import {
25
27
  } from './daemon.js';
26
28
  import { installStatusline, uninstallStatusline } from './inject/statusline.js';
27
29
  import { installPatcher, restorePatcher } from './inject/patcher.js';
30
+ import { detectSources } from './detect.js';
28
31
 
29
32
  /** Absolute path to this module's directory. */
30
33
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -91,27 +94,8 @@ function printSetupHint() {
91
94
  }
92
95
 
93
96
  /**
94
- * Write the bundled example config to the destination path. Confirms before
95
- * overwriting an existing file unless `force` is true.
96
- * @param {string} dest
97
- * @param {boolean} force
98
- * @returns {Promise<boolean>} true if written, false if skipped.
99
- */
100
- async function writeExampleConfig(dest, force) {
101
- const examplePath = path.join(ROOT, '.contextspin.example.json');
102
- const raw = await fsp.readFile(examplePath, 'utf8');
103
- if (fs.existsSync(dest) && !force) {
104
- console.log(`Config already exists at ${dest} (left unchanged).`);
105
- return false;
106
- }
107
- await fsp.writeFile(dest, raw);
108
- console.log(`Wrote example config to ${dest}`);
109
- return true;
110
- }
111
-
112
- /**
113
- * Run the setup command: create a config either non-interactively (example
114
- * config) or via an interactive prompt that builds a minimal config.
97
+ * Run the setup command: create a config either non-interactively (a REAL
98
+ * config built from detected sources) or via an interactive prompt.
115
99
  * @param {{ yes?: boolean }} opts
116
100
  * @returns {Promise<void>}
117
101
  */
@@ -119,11 +103,13 @@ async function runSetup(opts = {}) {
119
103
  const interactive = process.stdin.isTTY && !opts.yes;
120
104
 
121
105
  if (!interactive) {
122
- // Non-TTY or --yes: drop the example config unless one already exists.
106
+ // Non-TTY or --yes: write a real detected config unless one already exists.
123
107
  if (configExists()) {
124
108
  console.log(`Config already exists at ${CONFIG_PATH} (left unchanged).`);
125
109
  } else {
126
- await writeExampleConfig(CONFIG_PATH, false);
110
+ const cfg = normalizeConfig(defaultConfig(await detectSources()));
111
+ await saveConfig(cfg, CONFIG_PATH);
112
+ console.log(`Wrote a detected config to ${CONFIG_PATH}`);
127
113
  }
128
114
  console.log('');
129
115
  console.log('Next steps:');
@@ -169,35 +155,21 @@ async function runSetup(opts = {}) {
169
155
  : 30;
170
156
 
171
157
  /** @type {Array<object>} */
172
- const sources = [];
158
+ let sources = [];
173
159
  const seedAns = (
174
- await rl.question('Seed a couple of safe starter sources? (Y/n) ')
160
+ await rl.question(
161
+ 'Seed the safe starter sources detected for your environment? (Y/n) ',
162
+ )
175
163
  )
176
164
  .trim()
177
165
  .toLowerCase();
178
166
  if (seedAns !== 'n' && seedAns !== 'no') {
179
- // Safe starters: read-only `gh` queries that do nothing harmful.
180
- sources.push({
181
- type: 'cli',
182
- command: 'gh pr list --review-requested @me --json title,number --limit 3',
183
- format: 'PR #{{ number }} needs your review: {{ title }}',
184
- label: 'GitHub',
185
- cooldown: 120,
186
- maxSnippets: 3,
187
- });
188
- sources.push({
189
- type: 'cli',
190
- command: 'gh run list --json status,name,headBranch --limit 5',
191
- filter: '{{ status }} == failure',
192
- format: 'CI failing: {{ name }} on {{ headBranch }}',
193
- label: 'CI',
194
- cooldown: 60,
195
- maxSnippets: 2,
196
- });
167
+ // Read-only starters detected from the local environment (gh/glab).
168
+ sources = await detectSources();
197
169
  }
198
170
 
199
171
  const config = normalizeConfig({
200
- sources,
172
+ ...defaultConfig(sources),
201
173
  injection: { mode, refresh },
202
174
  });
203
175
  await saveConfig(config, CONFIG_PATH);
@@ -211,6 +183,102 @@ async function runSetup(opts = {}) {
211
183
  }
212
184
  }
213
185
 
186
+ /**
187
+ * The TARGET Claude settings file for a given scope: the project's gitignored
188
+ * settings.local.json when a projectDir is known, else the user settings.json.
189
+ * @param {string|undefined} projectDir
190
+ * @returns {string}
191
+ */
192
+ function targetSettingsPath(projectDir) {
193
+ if (projectDir) {
194
+ return path.join(path.resolve(projectDir), '.claude', 'settings.local.json');
195
+ }
196
+ return CLAUDE_SETTINGS_PATH;
197
+ }
198
+
199
+ /**
200
+ * Whether the statusLine in the scope's TARGET settings file already points at
201
+ * our wrapper. Best-effort: any read/parse/missing-file error -> false.
202
+ * @param {string|undefined} projectDir - Project scope dir, or undefined for user scope.
203
+ * @returns {boolean}
204
+ */
205
+ function statuslineIsOurs(projectDir) {
206
+ try {
207
+ const target = targetSettingsPath(projectDir);
208
+ if (!fs.existsSync(target)) return false;
209
+ const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
210
+ const sl = parsed && parsed.statusLine;
211
+ return !!(sl && typeof sl === 'object' && sl.command === STATUSLINE_SH);
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * The ENSURE flow: idempotent, non-interactive, safe to run every session
219
+ * (this is what the plugin SessionStart hook invokes). It:
220
+ * (a) creates a detected config if none exists,
221
+ * (b) wires the statusline if the mode is statusline/both and it is not
222
+ * already pointing at our wrapper, and
223
+ * (c) starts the daemon if it is not already running.
224
+ * Prints a concise one-line summary. Never throws on the normal paths; any
225
+ * error prints a clean line and the process still exits 0 (the hook depends on
226
+ * this — a non-zero exit would surface an error to the user every session).
227
+ * @returns {Promise<void>}
228
+ */
229
+ async function runEnsure() {
230
+ /** @type {string[]} */
231
+ const did = [];
232
+ try {
233
+ let createdConfig = false;
234
+ if (!configExists()) {
235
+ const cfg = normalizeConfig(defaultConfig(await detectSources()));
236
+ await saveConfig(cfg, CONFIG_PATH);
237
+ createdConfig = true;
238
+ did.push('created config');
239
+ }
240
+
241
+ const config = await loadConfig();
242
+ const mode =
243
+ config && config.injection && config.injection.mode
244
+ ? config.injection.mode
245
+ : 'statusline';
246
+
247
+ // Claude Code sets CLAUDE_PROJECT_DIR in hooks. When present we wire the
248
+ // scope-aware project settings.local.json (which outranks a repo's tracked
249
+ // statusLine); when absent we stay in user scope and do NOT guess from cwd.
250
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || undefined;
251
+
252
+ if (mode === 'statusline' || mode === 'both') {
253
+ // Always (re-)install: installStatusline is idempotent for the settings
254
+ // write, and re-running it every session refreshes the composed prior so a
255
+ // repo that later changes its own statusLine gets picked up. Only announce
256
+ // it as a fresh wiring the first time.
257
+ const wasOurs = statuslineIsOurs(projectDir);
258
+ await installStatusline(config, { projectDir });
259
+ if (!wasOurs) did.push('wired statusline');
260
+ }
261
+
262
+ if (!isDaemonRunning().running) {
263
+ startDaemonDetached();
264
+ did.push('started daemon');
265
+ }
266
+
267
+ if (did.length === 0) {
268
+ console.log('ContextSpin: already set up.');
269
+ } else {
270
+ console.log(
271
+ `ContextSpin: ${did.join(', ')}.` +
272
+ (createdConfig ? ` Edit ${CONFIG_PATH} to add your own sources.` : ''),
273
+ );
274
+ }
275
+ } catch (err) {
276
+ const message = err && err.message ? err.message : String(err);
277
+ // Never break the session-start hook: report and exit 0.
278
+ console.log(`ContextSpin: setup skipped (${message}).`);
279
+ }
280
+ }
281
+
214
282
  /**
215
283
  * Start the background daemon. Requires a valid config.
216
284
  * @returns {Promise<void>}
@@ -306,7 +374,7 @@ function resolveMode(optionMode, config) {
306
374
 
307
375
  /**
308
376
  * Run the inject command for the chosen mode (statusline / patcher / both).
309
- * @param {{ mode?: string }} opts
377
+ * @param {{ mode?: string, project?: string }} opts
310
378
  * @returns {Promise<void>}
311
379
  */
312
380
  async function runInject(opts = {}) {
@@ -318,9 +386,12 @@ async function runInject(opts = {}) {
318
386
  );
319
387
  }
320
388
 
389
+ const projectDir = opts.project || process.env.CLAUDE_PROJECT_DIR || undefined;
390
+
321
391
  if (mode === 'statusline' || mode === 'both') {
322
- const res = await installStatusline(config);
392
+ const res = await installStatusline(config, { projectDir });
323
393
  console.log('Statusline installed:');
394
+ console.log(` scope: ${res.scope}`);
324
395
  console.log(` script: ${res.statuslineSh}`);
325
396
  console.log(` renderer: ${res.statuslineJs}`);
326
397
  console.log(` settings: ${res.settingsPath}`);
@@ -357,7 +428,7 @@ async function runInject(opts = {}) {
357
428
 
358
429
  /**
359
430
  * Run the uninject command, reversing whichever injection mode is selected.
360
- * @param {{ mode?: string }} opts
431
+ * @param {{ mode?: string, project?: string }} opts
361
432
  * @returns {Promise<void>}
362
433
  */
363
434
  async function runUninject(opts = {}) {
@@ -369,8 +440,10 @@ async function runUninject(opts = {}) {
369
440
  );
370
441
  }
371
442
 
443
+ const projectDir = opts.project || process.env.CLAUDE_PROJECT_DIR || undefined;
444
+
372
445
  if (mode === 'statusline' || mode === 'both') {
373
- const res = await uninstallStatusline();
446
+ const res = await uninstallStatusline({ projectDir });
374
447
  if (res.removed) {
375
448
  console.log(
376
449
  res.restored
@@ -431,10 +504,17 @@ function buildProgram() {
431
504
 
432
505
  program
433
506
  .command('setup')
434
- .description('Create a ContextSpin config (interactive, or --yes for example)')
435
- .option('--yes', 'skip prompts and write the example config')
507
+ .description('Create a ContextSpin config (interactive, or --yes for a detected config)')
508
+ .option('--yes', 'skip prompts and write a detected config')
436
509
  .action(action(async (opts) => runSetup(opts)));
437
510
 
511
+ program
512
+ .command('ensure')
513
+ .description(
514
+ 'One-shot, idempotent setup (create config + wire statusline + start daemon)',
515
+ )
516
+ .action(async () => runEnsure());
517
+
438
518
  program
439
519
  .command('start')
440
520
  .description('Start the background daemon')
@@ -459,12 +539,22 @@ function buildProgram() {
459
539
  .command('inject')
460
540
  .description('Wire ContextSpin into Claude Code (statusline/patcher/both)')
461
541
  .option('--mode <m>', 'injection mode: statusline, patcher, or both')
542
+ .option(
543
+ '--project <dir>',
544
+ 'wire the project-scoped settings.local.json under <dir> (defaults to $CLAUDE_PROJECT_DIR); composes any statusline the repo ships',
545
+ process.env.CLAUDE_PROJECT_DIR,
546
+ )
462
547
  .action(action(async (opts) => runInject(opts)));
463
548
 
464
549
  program
465
550
  .command('uninject')
466
551
  .description('Remove ContextSpin from Claude Code')
467
552
  .option('--mode <m>', 'injection mode: statusline, patcher, or both')
553
+ .option(
554
+ '--project <dir>',
555
+ 'uninject from the project-scoped settings.local.json under <dir> (defaults to $CLAUDE_PROJECT_DIR)',
556
+ process.env.CLAUDE_PROJECT_DIR,
557
+ )
468
558
  .action(action(async (opts) => runUninject(opts)));
469
559
 
470
560
  // Default action: run when no subcommand is provided. Any leftover operand
package/src/config.js CHANGED
@@ -37,6 +37,16 @@ export const STATUSLINE_SH = path.join(STATE_DIR, "statusline.sh");
37
37
  /** Path to the generated statusline Node render script. */
38
38
  export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.js");
39
39
 
40
+ /**
41
+ * Path to the recorded prior statusLine commands (captured when we wrap an
42
+ * existing statusline so we can run it and prepend its output). Holds a MAP
43
+ * keyed by absolute project dir (with "" reserved for the user/no-project
44
+ * scope); each value is { command, type }. An old single-object file (with a
45
+ * top-level `command` field) is migrated to the "" entry on read. Entries are
46
+ * removed per-scope on uninstall.
47
+ */
48
+ export const PREV_STATUSLINE_PATH = path.join(STATE_DIR, "prev-statusline.json");
49
+
40
50
  /** Path to Claude Code's settings file (patched by the statusline injector). */
41
51
  export const CLAUDE_SETTINGS_PATH = path.join(HOME, ".claude", "settings.json");
42
52
 
@@ -124,6 +134,36 @@ export function normalizeConfig(raw) {
124
134
  return { ...input, injection, snippets, sources };
125
135
  }
126
136
 
137
+ /**
138
+ * Build a complete default config object from a set of sources. Mirrors the
139
+ * shipped example config's injection/snippets shape (statusline mode, 30s
140
+ * refresh, 5 visible, dedup on, a sensible priority order). The result is a
141
+ * plain config (NOT normalized) — pass it through normalizeConfig before use.
142
+ *
143
+ * @param {Array<object>} sources - Source objects (e.g. from detectSources).
144
+ * @returns {object} A default config: { sources, injection, snippets }.
145
+ */
146
+ export function defaultConfig(sources) {
147
+ return {
148
+ sources: Array.isArray(sources) ? sources : [],
149
+ injection: { mode: "statusline", refresh: 30, maxVisible: 5 },
150
+ snippets: {
151
+ deduplication: true,
152
+ cooldownAfterShown: 3,
153
+ priorityOrder: [
154
+ "review",
155
+ "incident",
156
+ "ci",
157
+ "slack",
158
+ "calendar",
159
+ "github",
160
+ "gitlab",
161
+ "jira",
162
+ ],
163
+ },
164
+ };
165
+ }
166
+
127
167
  /**
128
168
  * Validate a config (raw or normalized). Throws an Error with a clear message on
129
169
  * any problem; returns the same config object on success.
@@ -135,8 +175,12 @@ export function validateConfig(config) {
135
175
  if (!config || typeof config !== "object" || Array.isArray(config)) {
136
176
  throw new Error("Invalid config: expected a JSON object.");
137
177
  }
138
- if (!Array.isArray(config.sources) || config.sources.length === 0) {
139
- throw new Error('Invalid config: "sources" must be a non-empty array.');
178
+ // sources must be an array, but MAY be empty: a source-less config is valid
179
+ // the daemon polls nothing and the injectors degrade to no snippets, which is
180
+ // the correct "installed but not configured yet" state (and lets `ensure`
181
+ // wire the statusline + start the daemon without a hard failure).
182
+ if (!Array.isArray(config.sources)) {
183
+ throw new Error('Invalid config: "sources" must be an array.');
140
184
  }
141
185
 
142
186
  config.sources.forEach((src, i) => {
package/src/daemon.js CHANGED
@@ -41,7 +41,10 @@ export async function readCache() {
41
41
  * @returns {Promise<void>}
42
42
  */
43
43
  export async function writeCache(state) {
44
- const tmp = CACHE_PATH + ".tmp";
44
+ // Per-process temp name so the daemon and the statusline render script (which
45
+ // also writes the cache to bump shownCount) never share one .tmp and tear each
46
+ // other's writes. rename-onto-target stays atomic.
47
+ const tmp = CACHE_PATH + "." + process.pid + ".tmp";
45
48
  await fsp.writeFile(tmp, JSON.stringify(state, null, 2));
46
49
  await fsp.rename(tmp, CACHE_PATH);
47
50
  }
package/src/detect.js ADDED
@@ -0,0 +1,121 @@
1
+ // src/detect.js — best-effort, zero-network detection of the single starter source.
2
+ //
3
+ // ContextSpin has ONE job: show "review requests waiting on you" — the PRs/MRs
4
+ // where you are the requested reviewer — in the Claude Code statusline. We seed
5
+ // exactly one source for that, using whichever code-host CLI is already on PATH
6
+ // (and already authenticated), so there is zero token/secret setup.
7
+ //
8
+ // Detection heuristic (all local, no secrets, no network):
9
+ // - We probe PATH for the `gh` (GitHub CLI) and `glab` (GitLab CLI) binaries
10
+ // using a short, swallowed child-process check (`<tool> --version`). Anything
11
+ // that errors, times out, or exits non-zero is treated as "not present".
12
+ // - If `gh` is present we seed the GitHub "review requested of you" source.
13
+ // - Else if `glab` is present we seed the GitLab equivalent.
14
+ // - If NEITHER is present we still return the `gh` source as a graceful
15
+ // placeholder. cli sources fail gracefully per-source in the daemon runner,
16
+ // so a missing binary just yields no snippets rather than breaking anything —
17
+ // and the config is then a working template the user can edit.
18
+ //
19
+ // All format strings use the double-curly-brace token syntax understood by
20
+ // src/formatter.js. The returned source object has NO `id` — normalizeConfig
21
+ // assigns ids by index.
22
+
23
+ import { spawn } from "node:child_process";
24
+
25
+ /**
26
+ * Best-effort check whether a binary is on PATH by running `<tool> --version`.
27
+ * Swallows every failure (missing binary, non-zero exit, timeout, spawn error)
28
+ * and resolves to a boolean. Never throws.
29
+ *
30
+ * @param {string} tool - The binary name to probe (e.g. "gh").
31
+ * @param {number} [timeoutMs=2000] - Kill the probe after this long.
32
+ * @returns {Promise<boolean>}
33
+ */
34
+ function hasBinary(tool, timeoutMs = 2000) {
35
+ return new Promise((resolve) => {
36
+ let settled = false;
37
+ const done = (value) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ resolve(value);
41
+ };
42
+ let child;
43
+ try {
44
+ child = spawn(tool, ["--version"], { stdio: "ignore" });
45
+ } catch {
46
+ done(false);
47
+ return;
48
+ }
49
+ const timer = setTimeout(() => {
50
+ try {
51
+ child.kill("SIGKILL");
52
+ } catch {
53
+ // ignore
54
+ }
55
+ done(false);
56
+ }, timeoutMs);
57
+ if (timer.unref) timer.unref();
58
+ child.on("error", () => {
59
+ clearTimeout(timer);
60
+ done(false);
61
+ });
62
+ child.on("close", (code) => {
63
+ clearTimeout(timer);
64
+ done(code === 0);
65
+ });
66
+ });
67
+ }
68
+
69
+ /** The GitHub "review requests waiting on you" source. */
70
+ function ghSource() {
71
+ return {
72
+ type: "cli",
73
+ command: "gh pr list --review-requested @me --json number,title --limit 5",
74
+ format: "👀 review #{{ number }}: {{ title }}",
75
+ label: "review",
76
+ cooldown: 120,
77
+ maxSnippets: 3,
78
+ };
79
+ }
80
+
81
+ /** The GitLab "review requests waiting on you" source. */
82
+ function glabSource() {
83
+ return {
84
+ type: "cli",
85
+ command: "glab mr list --reviewer=@me --output json --per-page 5",
86
+ format: "👀 review !{{ iid }}: {{ title }}",
87
+ label: "review",
88
+ cooldown: 120,
89
+ maxSnippets: 3,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Detect the single safe, read-only starter source from the local environment.
95
+ *
96
+ * Best-effort and side-effect-free beyond local `<tool> --version` probes (no
97
+ * network). See the file header for the detection heuristic. Always returns a
98
+ * non-empty array holding exactly one source object WITHOUT an id
99
+ * (normalizeConfig assigns ids).
100
+ *
101
+ * @param {{ timeoutMs?: number }} [opts]
102
+ * @returns {Promise<Array<object>>}
103
+ */
104
+ export async function detectSources(opts = {}) {
105
+ const timeoutMs = opts.timeoutMs;
106
+
107
+ // Probe both CLIs in parallel; each probe swallows its own failures.
108
+ const [gh, glab] = await Promise.all([
109
+ hasBinary("gh", timeoutMs),
110
+ hasBinary("glab", timeoutMs),
111
+ ]);
112
+
113
+ if (gh) return [ghSource()];
114
+ if (glab) return [glabSource()];
115
+
116
+ // Neither present: return the gh source as a graceful, gracefully-failing
117
+ // placeholder the user can edit.
118
+ return [ghSource()];
119
+ }
120
+
121
+ export default detectSources;
@@ -1,6 +1,25 @@
1
1
  // src/inject/statusline.js — installs/uninstalls the Claude Code statusLine integration.
2
- // Generates a self-contained render script (always exits 0, reads+discards stdin)
3
- // and wires it into ~/.claude/settings.json under the camelCase `statusLine` key.
2
+ // Generates a self-contained render script (always exits 0, buffers stdin) and
3
+ // wires it into a Claude Code settings file under the camelCase `statusLine` key.
4
+ //
5
+ // SCOPE-AWARE + NON-DESTRUCTIVE:
6
+ //
7
+ // - User scope (no project dir): we patch the user ~/.claude/settings.json.
8
+ // - Project scope (a projectDir is known, e.g. CLAUDE_PROJECT_DIR in a hook):
9
+ // we patch <projectDir>/.claude/settings.local.json. That file is gitignored
10
+ // and OUTRANKS the project's tracked .claude/settings.json — so a repo that
11
+ // ships its own statusLine in settings.json no longer SHADOWS ContextSpin.
12
+ //
13
+ // - In either scope, if a statusLine command (other than ours) is currently
14
+ // effective, we record it in a PREV map (keyed by the absolute project dir,
15
+ // with "" reserved for the user scope) and the generated render script RUNS
16
+ // that prior command (piping Claude Code's stdin to it) and prints its output
17
+ // FIRST, then prints the ContextSpin snippet line on its own line beneath. The
18
+ // prior statusline is composed with ours, never discarded.
19
+ //
20
+ // - The render script picks the prior PER PROJECT at render time: it parses the
21
+ // stdin payload for the project dir and looks the prior command up in the PREV
22
+ // map by that dir, falling back to the user ("") entry.
4
23
 
5
24
  import fs from "node:fs";
6
25
  import fsp from "node:fs/promises";
@@ -9,6 +28,7 @@ import {
9
28
  STATE_DIR,
10
29
  STATUSLINE_SH,
11
30
  STATUSLINE_JS,
31
+ PREV_STATUSLINE_PATH,
12
32
  CACHE_PATH,
13
33
  CONFIG_PATH,
14
34
  CLAUDE_SETTINGS_PATH,
@@ -19,71 +39,220 @@ import {
19
39
  * for each status-bar refresh.
20
40
  *
21
41
  * Runtime behavior of the generated script:
22
- * - Reads and DISCARDS all of stdin (Claude Code pipes a JSON payload; we never
23
- * use it, but we must drain it so the writer never gets EPIPE).
24
- * - Reads the cache (tolerating a missing file -> exit 0 silently).
42
+ * - Reads and BUFFERS all of stdin (Claude Code pipes a JSON payload). We must
43
+ * consume it so the writer never gets EPIPE; we also feed it to a wrapped
44
+ * prior statusline command (below).
45
+ * - Tolerantly JSON-parses the buffered stdin to find the project dir (trying
46
+ * workspace.project_dir, then workspace.current_dir, then cwd), then looks up
47
+ * the prior command in the PREV map by that dir, falling back to the user ("")
48
+ * entry. If a prior command is found, it spawns that command via the shell,
49
+ * writes the buffered stdin to ITS stdin, captures its stdout with a 2000ms
50
+ * timeout (SIGKILL on timeout), and prints that output VERBATIM first (it may
51
+ * be multiple lines). Any failure here is swallowed.
52
+ * - Reads the cache (tolerating a missing file).
25
53
  * - Reads `cooldownAfterShown` from the config (fallback 3).
26
54
  * - Selects snippets where shownCount < cooldownAfterShown, picks the one with
27
55
  * the LOWEST shownCount then the most recent fetchedAt, bumps its shownCount,
28
56
  * and writes the cache back atomically.
29
- * - Prints that snippet's text on a single line; prints nothing if none eligible.
30
- * - Wraps EVERYTHING so any error still exits 0 with no output (never breaks the
31
- * user's status bar).
57
+ * - Prints that snippet's text on its OWN line beneath the prior output; prints
58
+ * nothing for the ContextSpin line if none eligible.
59
+ * - Wraps EVERYTHING so any error still exits 0 with whatever output succeeded
60
+ * (the prior statusline must never be lost and the bar must never break).
32
61
  *
33
- * The cache and config paths are baked into the script as string literals so the
34
- * generated file is fully self-contained and has no import dependencies.
62
+ * The cache, config, and prev-statusline-map paths are baked into the script as
63
+ * string literals so the generated file is fully self-contained with no imports
64
+ * beyond node builtins.
35
65
  *
36
66
  * @param {string} cachePath - Absolute path to the snippet cache JSON file.
37
67
  * @param {string} configPath - Absolute path to the ContextSpin config JSON file.
68
+ * @param {string} prevPath - Absolute path to the prev-statusline MAP JSON file.
38
69
  * @returns {string} The ESM source of the render script.
39
70
  */
40
- function buildRenderScript(cachePath, configPath) {
71
+ function buildRenderScript(cachePath, configPath, prevPath) {
41
72
  const CACHE = JSON.stringify(cachePath);
42
73
  const CONFIG = JSON.stringify(configPath);
43
- return `// contextspin statusline-render.js (generated) — prints one context snippet.
44
- // MUST always exit 0 and print nothing on error so the status bar never breaks.
74
+ const PREV = JSON.stringify(prevPath);
75
+ return `// contextspin statusline-render.js (generated) composes any prior
76
+ // statusline (looked up per-project) with one ContextSpin snippet line. MUST
77
+ // always exit 0 and never lose the prior statusline's output, so the user's
78
+ // status bar never breaks.
45
79
  import fs from "node:fs";
80
+ import { spawn } from "node:child_process";
46
81
 
47
82
  const CACHE_PATH = ${CACHE};
48
83
  const CONFIG_PATH = ${CONFIG};
84
+ const PREV_STATUSLINE_PATH = ${PREV};
49
85
 
50
- /** Drain and discard stdin so Claude Code's JSON pipe never gets EPIPE. */
51
- function drainStdin() {
86
+ /** Buffer ALL of stdin into a Buffer. Resolves on end/close/error/timeout. */
87
+ function readStdin() {
52
88
  return new Promise((resolve) => {
89
+ const chunks = [];
90
+ let done = false;
91
+ const finish = () => {
92
+ if (done) return;
93
+ done = true;
94
+ resolve(Buffer.concat(chunks));
95
+ };
53
96
  try {
54
97
  const stdin = process.stdin;
55
- stdin.on("error", () => {});
56
- stdin.on("data", () => {});
57
- stdin.on("end", () => resolve());
58
- stdin.on("close", () => resolve());
98
+ stdin.on("error", () => finish());
99
+ stdin.on("data", (chunk) =>
100
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
101
+ );
102
+ stdin.on("end", () => finish());
103
+ stdin.on("close", () => finish());
59
104
  stdin.resume();
60
105
  // Safety timer: don't hang forever if no EOF arrives.
61
- setTimeout(resolve, 250).unref?.();
106
+ setTimeout(finish, 250).unref?.();
62
107
  } catch {
63
- resolve();
108
+ finish();
109
+ }
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Tolerantly parse the buffered stdin payload for the project dir. Tries
115
+ * workspace.project_dir, then workspace.current_dir, then cwd. Returns "" on any
116
+ * failure (which falls back to the user-scope prev entry).
117
+ */
118
+ function projectDirFromStdin(stdinBuf) {
119
+ try {
120
+ const payload = JSON.parse(stdinBuf.toString("utf8"));
121
+ const ws = payload && typeof payload.workspace === "object" ? payload.workspace : {};
122
+ const dir =
123
+ (ws && ws.project_dir) ||
124
+ (ws && ws.current_dir) ||
125
+ (payload && payload.cwd) ||
126
+ "";
127
+ return typeof dir === "string" ? dir : "";
128
+ } catch {
129
+ return "";
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Read the prev-statusline MAP (keyed by absolute project dir, with "" for the
135
+ * user scope). Tolerates a missing/old file. An OLD single-object file (one with
136
+ * a top-level \`command\` field) is migrated in-memory to the "" (user) entry.
137
+ * Returns an object map (possibly empty); never throws.
138
+ */
139
+ function readPrevMap() {
140
+ let raw;
141
+ try {
142
+ raw = JSON.parse(fs.readFileSync(PREV_STATUSLINE_PATH, "utf8"));
143
+ } catch {
144
+ return {};
145
+ }
146
+ if (!raw || typeof raw !== "object") return {};
147
+ // Migrate an old single-object record to the user ("") entry.
148
+ if (typeof raw.command === "string") {
149
+ return { "": { command: raw.command, type: raw.type || "command" } };
150
+ }
151
+ return raw;
152
+ }
153
+
154
+ /**
155
+ * Resolve the prior statusline command for a given project dir from the map,
156
+ * falling back to the user ("") entry. Returns "" when none is recorded.
157
+ */
158
+ function priorCommandFor(projectDir) {
159
+ const map = readPrevMap();
160
+ // Try the raw dir, then its realpath (the install side keys by realpath, so a
161
+ // symlinked root still matches), then fall back to the user ("") entry.
162
+ const candidates = [];
163
+ if (projectDir) {
164
+ candidates.push(projectDir);
165
+ try { candidates.push(fs.realpathSync(projectDir)); } catch {}
166
+ }
167
+ candidates.push("");
168
+ for (const k of candidates) {
169
+ const entry = map[k];
170
+ if (entry && typeof entry === "object" && typeof entry.command === "string") {
171
+ return entry.command;
172
+ }
173
+ }
174
+ return "";
175
+ }
176
+
177
+ /**
178
+ * Run the recorded prior statusline command, feeding it the buffered stdin, and
179
+ * resolve with its captured stdout (string). Swallows every failure -> "".
180
+ */
181
+ function runPrevStatusline(command, stdinBuf) {
182
+ return new Promise((resolve) => {
183
+ if (!command) {
184
+ resolve("");
185
+ return;
186
+ }
187
+ let child;
188
+ try {
189
+ child = spawn(command, { shell: true });
190
+ } catch {
191
+ resolve("");
192
+ return;
193
+ }
194
+ let out = "";
195
+ let settled = false;
196
+ const finish = () => {
197
+ if (settled) return;
198
+ settled = true;
199
+ resolve(out);
200
+ };
201
+ const timer = setTimeout(() => {
202
+ try {
203
+ child.kill("SIGKILL");
204
+ } catch {
205
+ // ignore
206
+ }
207
+ finish();
208
+ }, 2000);
209
+ if (timer.unref) timer.unref();
210
+ if (child.stdout) {
211
+ child.stdout.setEncoding("utf8");
212
+ child.stdout.on("data", (chunk) => {
213
+ out += chunk;
214
+ });
215
+ }
216
+ if (child.stderr) child.stderr.on("data", () => {});
217
+ child.on("error", () => {
218
+ clearTimeout(timer);
219
+ finish();
220
+ });
221
+ child.on("close", () => {
222
+ clearTimeout(timer);
223
+ finish();
224
+ });
225
+ try {
226
+ if (child.stdin) {
227
+ child.stdin.on("error", () => {});
228
+ if (stdinBuf && stdinBuf.length) child.stdin.write(stdinBuf);
229
+ child.stdin.end();
230
+ }
231
+ } catch {
232
+ // ignore: prior command may not read stdin
64
233
  }
65
234
  });
66
235
  }
67
236
 
68
- /** Atomically replace a JSON file (write tmp then rename). */
237
+ /** Atomically replace a JSON file (write tmp then rename). The temp name is
238
+ // per-process so this render script and the daemon never share one .tmp and
239
+ // tear each other's cache writes. */
69
240
  function writeJsonAtomic(filePath, data) {
70
- const tmp = filePath + ".tmp";
241
+ const tmp = filePath + "." + process.pid + ".tmp";
71
242
  fs.writeFileSync(tmp, JSON.stringify(data));
72
243
  fs.renameSync(tmp, filePath);
73
244
  }
74
245
 
75
- async function main() {
76
- await drainStdin();
77
-
78
- // Missing cache -> nothing to show.
246
+ /** Compute the ContextSpin snippet line (may be ""); bumps shownCount. */
247
+ function contextSpinLine() {
79
248
  let cache;
80
249
  try {
81
250
  cache = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
82
251
  } catch {
83
- return; // no output
252
+ return "";
84
253
  }
85
254
  const snippets = Array.isArray(cache && cache.snippets) ? cache.snippets : [];
86
- if (snippets.length === 0) return;
255
+ if (snippets.length === 0) return "";
87
256
 
88
257
  // cooldownAfterShown from config (fallback 3).
89
258
  let cooldownAfterShown = 3;
@@ -95,13 +264,11 @@ async function main() {
95
264
  // keep fallback
96
265
  }
97
266
 
98
- // Eligible: shownCount < cooldownAfterShown.
99
267
  const eligible = snippets.filter(
100
268
  (s) => s && typeof s.text === "string" && (s.shownCount || 0) < cooldownAfterShown
101
269
  );
102
- if (eligible.length === 0) return;
270
+ if (eligible.length === 0) return "";
103
271
 
104
- // Pick lowest shownCount, then most recent fetchedAt.
105
272
  eligible.sort((a, b) => {
106
273
  const ca = a.shownCount || 0;
107
274
  const cb = b.shownCount || 0;
@@ -112,7 +279,6 @@ async function main() {
112
279
  });
113
280
  const chosen = eligible[0];
114
281
 
115
- // Bump shownCount on the chosen snippet within the original array and persist.
116
282
  chosen.shownCount = (chosen.shownCount || 0) + 1;
117
283
  try {
118
284
  writeJsonAtomic(CACHE_PATH, cache);
@@ -120,13 +286,53 @@ async function main() {
120
286
  // If we cannot persist, still show the snippet this time.
121
287
  }
122
288
 
123
- // Single line of output. Await the write callback so the bytes are flushed to
124
- // the pipe before process.exit (which does not wait for async stdout writes).
125
- await new Promise((resolve) => {
126
- process.stdout.write(String(chosen.text).replace(/\\r?\\n/g, " "), resolve);
289
+ return String(chosen.text).replace(/\\r?\\n/g, " ");
290
+ }
291
+
292
+ /** Write a string to stdout, awaiting the flush callback. */
293
+ function writeOut(text) {
294
+ return new Promise((resolve) => {
295
+ try {
296
+ process.stdout.write(text, resolve);
297
+ } catch {
298
+ resolve();
299
+ }
127
300
  });
128
301
  }
129
302
 
303
+ async function main() {
304
+ const stdinBuf = await readStdin();
305
+
306
+ // (a) Prior statusline output FIRST (verbatim, possibly multi-line), looked up
307
+ // per-project from the PREV map.
308
+ let prevOut = "";
309
+ try {
310
+ const projectDir = projectDirFromStdin(stdinBuf);
311
+ const command = priorCommandFor(projectDir);
312
+ prevOut = await runPrevStatusline(command, stdinBuf);
313
+ } catch {
314
+ prevOut = "";
315
+ }
316
+
317
+ // (b) ContextSpin snippet line.
318
+ let line = "";
319
+ try {
320
+ line = contextSpinLine();
321
+ } catch {
322
+ line = "";
323
+ }
324
+
325
+ // (c) Compose: prior output, then our line on its own line beneath. We only
326
+ // insert a separating newline when there is prior output that does not
327
+ // already end in one, so a lone ContextSpin line stays a single clean line.
328
+ let composed = prevOut;
329
+ if (line) {
330
+ if (composed && !composed.endsWith("\\n")) composed += "\\n";
331
+ composed += line;
332
+ }
333
+ if (composed) await writeOut(composed);
334
+ }
335
+
130
336
  main()
131
337
  .then(() => process.exit(0))
132
338
  .catch(() => process.exit(0));
@@ -148,6 +354,20 @@ async function readJsonSafe(filePath, fallback) {
148
354
  }
149
355
  }
150
356
 
357
+ /**
358
+ * Synchronous JSON read returning a fallback on any read/parse error.
359
+ * @param {string} filePath
360
+ * @param {*} fallback
361
+ * @returns {*}
362
+ */
363
+ function readJsonSafeSync(filePath, fallback) {
364
+ try {
365
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
366
+ } catch {
367
+ return fallback;
368
+ }
369
+ }
370
+
151
371
  /**
152
372
  * Atomically write a pretty-printed JSON file (write tmp then rename).
153
373
  * @param {string} filePath
@@ -160,62 +380,184 @@ async function writeJsonAtomic(filePath, data) {
160
380
  await fsp.rename(tmp, filePath);
161
381
  }
162
382
 
383
+ /**
384
+ * Read the prev-statusline MAP from disk: an object keyed by absolute project
385
+ * dir (with "" reserved for the user scope), each value { command, type }.
386
+ *
387
+ * Tolerates a missing/unparseable file (-> {}). Migrates an OLD single-object
388
+ * file (one with a top-level `command` field) by treating it as the "" (user)
389
+ * entry. Never throws.
390
+ *
391
+ * @returns {Record<string, {command: string, type: string}>}
392
+ */
393
+ function readPrevMap() {
394
+ const raw = readJsonSafeSync(PREV_STATUSLINE_PATH, null);
395
+ if (!raw || typeof raw !== "object") return {};
396
+ // Migrate an old single-object record to the user ("") entry.
397
+ if (typeof raw.command === "string") {
398
+ return { "": { command: raw.command, type: raw.type || "command" } };
399
+ }
400
+ return raw;
401
+ }
402
+
403
+ /**
404
+ * Persist the prev-statusline MAP atomically.
405
+ * @param {Record<string, {command: string, type: string}>} map
406
+ * @returns {Promise<void>}
407
+ */
408
+ async function writePrevMap(map) {
409
+ await writeJsonAtomic(PREV_STATUSLINE_PATH, map);
410
+ }
411
+
412
+ /**
413
+ * Resolve the statusLine command currently configured in a settings file (if
414
+ * any), ignoring our own wrapper. Returns null when the file has no usable
415
+ * non-ours statusLine command.
416
+ *
417
+ * @param {string} settingsPath
418
+ * @returns {{command: string, type: string}|null}
419
+ */
420
+ function priorFromSettings(settingsPath) {
421
+ const settings = readJsonSafeSync(settingsPath, null);
422
+ const sl = settings && typeof settings === "object" ? settings.statusLine : null;
423
+ if (
424
+ sl &&
425
+ typeof sl === "object" &&
426
+ typeof sl.command === "string" &&
427
+ sl.command &&
428
+ sl.command !== STATUSLINE_SH
429
+ ) {
430
+ return { command: sl.command, type: sl.type || "command" };
431
+ }
432
+ return null;
433
+ }
434
+
163
435
  /**
164
436
  * @typedef {Object} InstallStatuslineResult
165
437
  * @property {string} statuslineSh - Path to the generated bash wrapper.
166
438
  * @property {string} statuslineJs - Path to the generated Node render script.
167
439
  * @property {string} settingsPath - Path to the patched Claude settings file.
440
+ * @property {"project"|"user"} scope - Whether we wrote project or user settings.
168
441
  * @property {boolean} backedUp - Whether an existing statusLine was backed up.
442
+ * @property {boolean} composed - Whether we wrapped an existing statusline
443
+ * (its output is composed above the ContextSpin line).
169
444
  * @property {string|null} warning - Human-readable warning, or null.
170
445
  */
171
446
 
172
447
  /**
173
- * Install the ContextSpin statusline integration:
174
- * - Writes the self-contained render script to STATUSLINE_JS.
175
- * - Writes an executable bash wrapper to STATUSLINE_SH that execs the render
176
- * script with stderr silenced.
177
- * - Patches ~/.claude/settings.json so `statusLine` points at our wrapper, with
178
- * `refreshInterval` in SECONDS (from config.injection.refresh). If an existing
179
- * statusLine command (other than ours) is present, it is backed up once.
448
+ * Install the ContextSpin statusline integration (SCOPE-AWARE, NON-DESTRUCTIVE).
449
+ *
450
+ * TARGET settings file + PREV map KEY:
451
+ * - If opts.projectDir is set: TARGET = <projectDir>/.claude/settings.local.json
452
+ * (gitignored, outranks the tracked settings.json); KEY = the resolved
453
+ * absolute projectDir.
454
+ * - Else: TARGET = the user ~/.claude/settings.json; KEY = "" (user scope).
455
+ *
456
+ * PRIOR detection (the statusline currently effective and NOT ours, to compose):
457
+ * - If projectDir set: the project's tracked .claude/settings.json statusLine if
458
+ * present and not ours; else the user settings.json statusLine if not ours;
459
+ * else none.
460
+ * - Else: the user settings.json statusLine if present and not ours; else none.
461
+ * We never treat our own STATUSLINE_SH as a prior, and record the detected prior
462
+ * into the PREV map under KEY (refreshing it if it differs).
180
463
  *
181
464
  * @param {object} config - Normalized ContextSpin config (uses injection.refresh).
465
+ * @param {{ projectDir?: string }} [opts]
182
466
  * @returns {Promise<InstallStatuslineResult>}
183
467
  */
184
- export async function installStatusline(config) {
468
+ export async function installStatusline(config, opts = {}) {
185
469
  await fsp.mkdir(STATE_DIR, { recursive: true });
186
470
 
187
- // (1) Render script.
188
- const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH);
471
+ // Canonicalize with realpath so the PREV-map key matches whatever the render
472
+ // script derives from Claude Code's stdin (symlinked roots like macOS /var vs
473
+ // /private/var would otherwise diverge). Fall back to path.resolve if realpath
474
+ // throws (e.g. the dir does not exist yet).
475
+ let projectDir = null;
476
+ if (opts && typeof opts.projectDir === "string" && opts.projectDir) {
477
+ try {
478
+ projectDir = fs.realpathSync(opts.projectDir);
479
+ } catch {
480
+ projectDir = path.resolve(opts.projectDir);
481
+ }
482
+ }
483
+
484
+ // Resolve TARGET settings file + PREV-map KEY by scope.
485
+ const scope = projectDir ? "project" : "user";
486
+ const targetPath = projectDir
487
+ ? path.join(projectDir, ".claude", "settings.local.json")
488
+ : CLAUDE_SETTINGS_PATH;
489
+ const key = projectDir || "";
490
+
491
+ // (1) PRIOR detection. We look at the *currently effective* non-ours
492
+ // statusLine so the render script can compose it.
493
+ let prior = null;
494
+ if (projectDir) {
495
+ // The tracked project settings.json (which currently shadows us), then fall
496
+ // back to the user settings.json.
497
+ prior =
498
+ priorFromSettings(path.join(projectDir, ".claude", "settings.json")) ||
499
+ priorFromSettings(CLAUDE_SETTINGS_PATH);
500
+ } else {
501
+ prior = priorFromSettings(CLAUDE_SETTINGS_PATH);
502
+ }
503
+
504
+ // (2) Record the prior into the PREV map under KEY. Refresh the entry if it
505
+ // differs; never record our own STATUSLINE_SH (priorFromSettings already
506
+ // excludes it). When there is no prior, drop any stale entry for this KEY.
507
+ const map = readPrevMap();
508
+ let composed = false;
509
+ if (prior && prior.command && prior.command !== STATUSLINE_SH) {
510
+ // Detected a real prior at this scope -> record/refresh it, so a repo that
511
+ // later changes its own statusLine is picked up on the next `ensure`.
512
+ map[key] = { command: prior.command, type: prior.type || "command" };
513
+ composed = true;
514
+ } else if (scope === "project") {
515
+ // Project priors come from the TRACKED settings.json, which we never write,
516
+ // so "no prior" means the repo genuinely has no statusLine -> drop any stale
517
+ // entry rather than keep running a command the repo has since removed.
518
+ if (map[key]) delete map[key];
519
+ composed = false;
520
+ } else {
521
+ // User scope: the prior source IS the file we overwrite with our wrapper, so
522
+ // "no prior" usually just means our wrapper is already installed. Keep any
523
+ // previously-recorded original prior.
524
+ composed = !!(map[key] && map[key].command);
525
+ }
526
+ await writePrevMap(map);
527
+
528
+ // (3) Render script (knows the prev-statusline MAP path) + bash wrapper.
529
+ const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH);
189
530
  await fsp.writeFile(STATUSLINE_JS, renderSource);
190
531
 
191
- // (2) Bash wrapper. Silence stderr so node warnings never reach the status bar.
532
+ // Silence stderr so node warnings never reach the status bar.
192
533
  const shSource = `#!/usr/bin/env bash\nexec node ${JSON.stringify(STATUSLINE_JS)} 2>/dev/null\n`;
193
534
  await fsp.writeFile(STATUSLINE_SH, shSource);
194
535
  await fsp.chmod(STATUSLINE_SH, 0o755);
195
536
 
196
- // (3) Patch Claude settings.
197
- await fsp.mkdir(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
198
- const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, {});
537
+ // (4) JSON-MERGE our statusLine into TARGET, preserving every other key.
538
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
539
+ const targetExisted = fs.existsSync(targetPath);
540
+ const settings = await readJsonSafe(targetPath, {});
199
541
  const settingsObj = settings && typeof settings === "object" ? settings : {};
200
542
 
201
543
  let backedUp = false;
202
544
  let warning = null;
203
545
 
204
- const existing = settingsObj.statusLine;
546
+ // If TARGET already held a non-ours statusLine, back it up once.
547
+ const targetExisting = settingsObj.statusLine;
205
548
  if (
206
- existing &&
207
- typeof existing === "object" &&
208
- existing.command &&
209
- existing.command !== STATUSLINE_SH
549
+ targetExisted &&
550
+ targetExisting &&
551
+ typeof targetExisting === "object" &&
552
+ typeof targetExisting.command === "string" &&
553
+ targetExisting.command &&
554
+ targetExisting.command !== STATUSLINE_SH
210
555
  ) {
211
- const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
556
+ const backupPath = targetPath + ".contextspin.bak";
212
557
  if (!fs.existsSync(backupPath)) {
213
- await fsp.copyFile(CLAUDE_SETTINGS_PATH, backupPath);
558
+ await fsp.copyFile(targetPath, backupPath);
214
559
  backedUp = true;
215
560
  }
216
- warning =
217
- `Existing statusLine command was overwritten (\`${existing.command}\`). ` +
218
- `A backup of your settings is at ${backupPath}. Run \`contextspin uninject\` to restore it.`;
219
561
  }
220
562
 
221
563
  const refresh =
@@ -230,13 +572,26 @@ export async function installStatusline(config) {
230
572
  refreshInterval: refresh, // SECONDS
231
573
  };
232
574
 
233
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settingsObj);
575
+ await writeJsonAtomic(targetPath, settingsObj);
576
+
577
+ if (composed) {
578
+ const priorCmd = prior ? prior.command : (map[key] && map[key].command);
579
+ warning =
580
+ `Existing statusLine command (\`${priorCmd}\`) is preserved: ` +
581
+ `ContextSpin runs it and shows its output above the ContextSpin line. ` +
582
+ (scope === "project"
583
+ ? `Wired into ${targetPath} (gitignored; outranks the tracked settings.json). `
584
+ : ``) +
585
+ `Run \`contextspin uninject\` to restore it.`;
586
+ }
234
587
 
235
588
  return {
236
589
  statuslineSh: STATUSLINE_SH,
237
590
  statuslineJs: STATUSLINE_JS,
238
- settingsPath: CLAUDE_SETTINGS_PATH,
591
+ settingsPath: targetPath,
592
+ scope,
239
593
  backedUp,
594
+ composed,
240
595
  warning,
241
596
  };
242
597
  }
@@ -245,24 +600,75 @@ export async function installStatusline(config) {
245
600
  * @typedef {Object} UninstallStatuslineResult
246
601
  * @property {boolean} removed - Whether our statusLine entry was removed.
247
602
  * @property {boolean} restored - Whether settings were restored from backup.
248
- * @property {string} settingsPath - Path to the Claude settings file.
603
+ * @property {string} settingsPath - Path to the Claude settings file operated on.
604
+ * @property {"project"|"user"} scope - Which scope was operated on.
249
605
  * @property {string|null} note - Human-readable note, or null.
250
606
  */
251
607
 
252
608
  /**
253
- * Uninstall the ContextSpin statusline integration. If the current
254
- * `statusLine.command` is ours, restore the `.contextspin.bak` backup when
255
- * present, otherwise just drop the `statusLine` key.
609
+ * Remove a scope's entry from the prev-statusline MAP (best-effort). When the
610
+ * map becomes empty the file is removed; otherwise it is rewritten.
611
+ * @param {string} key - The PREV-map key ("" for user scope, else absolute dir).
612
+ * @returns {Promise<void>}
613
+ */
614
+ async function removePrevEntry(key) {
615
+ try {
616
+ const map = readPrevMap();
617
+ if (Object.prototype.hasOwnProperty.call(map, key)) {
618
+ delete map[key];
619
+ }
620
+ if (Object.keys(map).length === 0) {
621
+ await fsp.unlink(PREV_STATUSLINE_PATH).catch(() => {});
622
+ } else {
623
+ await writePrevMap(map);
624
+ }
625
+ } catch {
626
+ // best effort
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Uninstall the ContextSpin statusline integration (SCOPE-AWARE reverse).
632
+ *
633
+ * - Project scope (opts.projectDir set): operate on
634
+ * <projectDir>/.claude/settings.local.json. If a `.contextspin.bak` exists,
635
+ * restore it; else JSON-merge to delete just the `statusLine` key (preserving
636
+ * other keys). Remove that project's entry from the PREV map.
637
+ * - User scope: operate on the user ~/.claude/settings.json the same way and
638
+ * remove the "" (user) PREV entry.
256
639
  *
640
+ * @param {{ projectDir?: string }} [opts]
257
641
  * @returns {Promise<UninstallStatuslineResult>}
258
642
  */
259
- export async function uninstallStatusline() {
260
- const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, null);
643
+ export async function uninstallStatusline(opts = {}) {
644
+ // Canonicalize with realpath so the PREV-map key matches whatever the render
645
+ // script derives from Claude Code's stdin (symlinked roots like macOS /var vs
646
+ // /private/var would otherwise diverge). Fall back to path.resolve if realpath
647
+ // throws (e.g. the dir does not exist yet).
648
+ let projectDir = null;
649
+ if (opts && typeof opts.projectDir === "string" && opts.projectDir) {
650
+ try {
651
+ projectDir = fs.realpathSync(opts.projectDir);
652
+ } catch {
653
+ projectDir = path.resolve(opts.projectDir);
654
+ }
655
+ }
656
+
657
+ const scope = projectDir ? "project" : "user";
658
+ const targetPath = projectDir
659
+ ? path.join(projectDir, ".claude", "settings.local.json")
660
+ : CLAUDE_SETTINGS_PATH;
661
+ const key = projectDir || "";
662
+
663
+ const settings = await readJsonSafe(targetPath, null);
261
664
  if (!settings || typeof settings !== "object") {
665
+ // Nothing in TARGET, but still drop any recorded prev entry for this scope.
666
+ await removePrevEntry(key);
262
667
  return {
263
668
  removed: false,
264
669
  restored: false,
265
- settingsPath: CLAUDE_SETTINGS_PATH,
670
+ settingsPath: targetPath,
671
+ scope,
266
672
  note: "No Claude settings file found; nothing to uninstall.",
267
673
  };
268
674
  }
@@ -272,39 +678,46 @@ export async function uninstallStatusline() {
272
678
  current && typeof current === "object" && current.command === STATUSLINE_SH;
273
679
 
274
680
  if (!isOurs) {
681
+ await removePrevEntry(key);
275
682
  return {
276
683
  removed: false,
277
684
  restored: false,
278
- settingsPath: CLAUDE_SETTINGS_PATH,
685
+ settingsPath: targetPath,
686
+ scope,
279
687
  note: "statusLine is not managed by ContextSpin; left unchanged.",
280
688
  };
281
689
  }
282
690
 
283
- const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
691
+ const backupPath = targetPath + ".contextspin.bak";
284
692
  if (fs.existsSync(backupPath)) {
285
693
  const backup = await readJsonSafe(backupPath, null);
286
694
  if (backup && typeof backup === "object") {
287
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, backup);
695
+ await writeJsonAtomic(targetPath, backup);
288
696
  try {
289
697
  await fsp.unlink(backupPath);
290
698
  } catch {
291
699
  // best effort
292
700
  }
701
+ await removePrevEntry(key);
293
702
  return {
294
703
  removed: true,
295
704
  restored: true,
296
- settingsPath: CLAUDE_SETTINGS_PATH,
705
+ settingsPath: targetPath,
706
+ scope,
297
707
  note: "Restored previous Claude settings from backup.",
298
708
  };
299
709
  }
300
710
  }
301
711
 
712
+ // No backup: JSON-merge to delete just our statusLine key (preserve the rest).
302
713
  delete settings.statusLine;
303
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
714
+ await writeJsonAtomic(targetPath, settings);
715
+ await removePrevEntry(key);
304
716
  return {
305
717
  removed: true,
306
718
  restored: false,
307
- settingsPath: CLAUDE_SETTINGS_PATH,
719
+ settingsPath: targetPath,
720
+ scope,
308
721
  note: "Removed the ContextSpin statusLine entry.",
309
722
  };
310
723
  }