contextspin 0.2.0 → 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.2.0",
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
@@ -184,14 +184,29 @@ async function runSetup(opts = {}) {
184
184
  }
185
185
 
186
186
  /**
187
- * Whether the Claude Code statusLine is already pointing at our wrapper.
188
- * Best-effort: any read/parse/missing-file error -> false.
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.
189
203
  * @returns {boolean}
190
204
  */
191
- function statuslineIsOurs() {
205
+ function statuslineIsOurs(projectDir) {
192
206
  try {
193
- if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) return false;
194
- const parsed = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
207
+ const target = targetSettingsPath(projectDir);
208
+ if (!fs.existsSync(target)) return false;
209
+ const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
195
210
  const sl = parsed && parsed.statusLine;
196
211
  return !!(sl && typeof sl === 'object' && sl.command === STATUSLINE_SH);
197
212
  } catch {
@@ -229,9 +244,19 @@ async function runEnsure() {
229
244
  ? config.injection.mode
230
245
  : 'statusline';
231
246
 
232
- if ((mode === 'statusline' || mode === 'both') && !statuslineIsOurs()) {
233
- await installStatusline(config);
234
- did.push('wired statusline');
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');
235
260
  }
236
261
 
237
262
  if (!isDaemonRunning().running) {
@@ -349,7 +374,7 @@ function resolveMode(optionMode, config) {
349
374
 
350
375
  /**
351
376
  * Run the inject command for the chosen mode (statusline / patcher / both).
352
- * @param {{ mode?: string }} opts
377
+ * @param {{ mode?: string, project?: string }} opts
353
378
  * @returns {Promise<void>}
354
379
  */
355
380
  async function runInject(opts = {}) {
@@ -361,9 +386,12 @@ async function runInject(opts = {}) {
361
386
  );
362
387
  }
363
388
 
389
+ const projectDir = opts.project || process.env.CLAUDE_PROJECT_DIR || undefined;
390
+
364
391
  if (mode === 'statusline' || mode === 'both') {
365
- const res = await installStatusline(config);
392
+ const res = await installStatusline(config, { projectDir });
366
393
  console.log('Statusline installed:');
394
+ console.log(` scope: ${res.scope}`);
367
395
  console.log(` script: ${res.statuslineSh}`);
368
396
  console.log(` renderer: ${res.statuslineJs}`);
369
397
  console.log(` settings: ${res.settingsPath}`);
@@ -400,7 +428,7 @@ async function runInject(opts = {}) {
400
428
 
401
429
  /**
402
430
  * Run the uninject command, reversing whichever injection mode is selected.
403
- * @param {{ mode?: string }} opts
431
+ * @param {{ mode?: string, project?: string }} opts
404
432
  * @returns {Promise<void>}
405
433
  */
406
434
  async function runUninject(opts = {}) {
@@ -412,8 +440,10 @@ async function runUninject(opts = {}) {
412
440
  );
413
441
  }
414
442
 
443
+ const projectDir = opts.project || process.env.CLAUDE_PROJECT_DIR || undefined;
444
+
415
445
  if (mode === 'statusline' || mode === 'both') {
416
- const res = await uninstallStatusline();
446
+ const res = await uninstallStatusline({ projectDir });
417
447
  if (res.removed) {
418
448
  console.log(
419
449
  res.restored
@@ -509,12 +539,22 @@ function buildProgram() {
509
539
  .command('inject')
510
540
  .description('Wire ContextSpin into Claude Code (statusline/patcher/both)')
511
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
+ )
512
547
  .action(action(async (opts) => runInject(opts)));
513
548
 
514
549
  program
515
550
  .command('uninject')
516
551
  .description('Remove ContextSpin from Claude Code')
517
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
+ )
518
558
  .action(action(async (opts) => runUninject(opts)));
519
559
 
520
560
  // Default action: run when no subcommand is provided. Any leftover operand
package/src/config.js CHANGED
@@ -38,9 +38,12 @@ export const STATUSLINE_SH = path.join(STATE_DIR, "statusline.sh");
38
38
  export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.js");
39
39
 
40
40
  /**
41
- * Path to the recorded prior statusLine command (captured when we wrap an
42
- * existing statusline so we can run it and prepend its output). Holds
43
- * { command, type }. Removed on uninstall.
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.
44
47
  */
45
48
  export const PREV_STATUSLINE_PATH = path.join(STATE_DIR, "prev-statusline.json");
46
49
 
@@ -148,6 +151,7 @@ export function defaultConfig(sources) {
148
151
  deduplication: true,
149
152
  cooldownAfterShown: 3,
150
153
  priorityOrder: [
154
+ "review",
151
155
  "incident",
152
156
  "ci",
153
157
  "slack",
package/src/detect.js CHANGED
@@ -1,24 +1,23 @@
1
- // src/detect.js — best-effort, zero-network detection of safe starter sources.
1
+ // src/detect.js — best-effort, zero-network detection of the single starter source.
2
2
  //
3
- // Detection heuristics (all local, no secrets, no network):
4
- // - We probe PATH for the `gh` (GitHub CLI), `glab` (GitLab CLI), and
5
- // `kubectl` binaries using a short, swallowed child-process check
6
- // (`<tool> --version`). Anything that errors, times out, or exits non-zero
7
- // is treated as "not present".
8
- // - If `gh` is present we seed two GitHub sources: PRs that requested your
9
- // review, and failing CI runs.
10
- // - Else if `glab` is present we seed the GitLab equivalents.
11
- // - If NEITHER `gh` nor `glab` is present we still return the `gh` pair as a
12
- // sensible placeholder. cli sources fail gracefully per-source in the
13
- // daemon runner, so a missing binary just yields no snippets rather than
14
- // breaking anything — and the config is then a working template the user
15
- // can edit.
16
- // - `kubectl` is probed for future use / informational purposes; we do not
17
- // seed a kubectl source today because a safe, universally-meaningful
18
- // read-only query is cluster-specific.
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.
19
7
  //
20
- // All format/filter strings use the double-curly-brace token syntax understood
21
- // by src/formatter.js. Returned source objects have NO `id` normalizeConfig
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
22
21
  // assigns ids by index.
23
22
 
24
23
  import { spawn } from "node:child_process";
@@ -67,58 +66,37 @@ function hasBinary(tool, timeoutMs = 2000) {
67
66
  });
68
67
  }
69
68
 
70
- /** The GitHub starter pair (PRs needing review + failing CI). */
71
- function ghSources() {
72
- return [
73
- {
74
- type: "cli",
75
- command:
76
- "gh pr list --review-requested @me --json title,number --limit 3",
77
- format: "PR #{{ number }} needs review: {{ title }}",
78
- label: "GitHub",
79
- cooldown: 120,
80
- maxSnippets: 3,
81
- },
82
- {
83
- type: "cli",
84
- command: "gh run list --json status,name,headBranch --limit 5",
85
- filter: "{{ status }} == failure",
86
- format: "CI failing: {{ name }} on {{ headBranch }}",
87
- label: "CI",
88
- cooldown: 60,
89
- maxSnippets: 2,
90
- },
91
- ];
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
+ };
92
79
  }
93
80
 
94
- /** The GitLab starter pair (MRs needing review + failing CI). */
95
- function glabSources() {
96
- return [
97
- {
98
- type: "cli",
99
- command: "glab mr list --reviewer=@me --output json --per-page 3",
100
- format: "MR !{{ iid }} needs review: {{ title }}",
101
- label: "GitLab",
102
- cooldown: 120,
103
- maxSnippets: 3,
104
- },
105
- {
106
- type: "cli",
107
- command: "glab ci list --status failed --output json --per-page 5",
108
- format: "CI failed: {{ ref }} (#{{ id }})",
109
- label: "CI",
110
- cooldown: 60,
111
- maxSnippets: 2,
112
- },
113
- ];
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
+ };
114
91
  }
115
92
 
116
93
  /**
117
- * Detect a set of safe, read-only starter sources from the local environment.
94
+ * Detect the single safe, read-only starter source from the local environment.
118
95
  *
119
96
  * Best-effort and side-effect-free beyond local `<tool> --version` probes (no
120
- * network). See the file header for the detection heuristics. Always returns a
121
- * non-empty array of source objects WITHOUT ids (normalizeConfig assigns ids).
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).
122
100
  *
123
101
  * @param {{ timeoutMs?: number }} [opts]
124
102
  * @returns {Promise<Array<object>>}
@@ -126,19 +104,18 @@ function glabSources() {
126
104
  export async function detectSources(opts = {}) {
127
105
  const timeoutMs = opts.timeoutMs;
128
106
 
129
- // Probe the three tools in parallel; each probe swallows its own failures.
130
- const [gh, glab /*, kubectl */] = await Promise.all([
107
+ // Probe both CLIs in parallel; each probe swallows its own failures.
108
+ const [gh, glab] = await Promise.all([
131
109
  hasBinary("gh", timeoutMs),
132
110
  hasBinary("glab", timeoutMs),
133
- hasBinary("kubectl", timeoutMs),
134
111
  ]);
135
112
 
136
- if (gh) return ghSources();
137
- if (glab) return glabSources();
113
+ if (gh) return [ghSource()];
114
+ if (glab) return [glabSource()];
138
115
 
139
- // Neither present: return the gh pair as a sensible, gracefully-failing
116
+ // Neither present: return the gh source as a graceful, gracefully-failing
140
117
  // placeholder the user can edit.
141
- return ghSources();
118
+ return [ghSource()];
142
119
  }
143
120
 
144
121
  export default detectSources;
@@ -1,12 +1,25 @@
1
1
  // src/inject/statusline.js — installs/uninstalls the Claude Code statusLine integration.
2
2
  // Generates a self-contained render script (always exits 0, buffers stdin) and
3
- // wires it into ~/.claude/settings.json under the camelCase `statusLine` key.
3
+ // wires it into a Claude Code settings file under the camelCase `statusLine` key.
4
4
  //
5
- // NON-DESTRUCTIVE: if the user already has a statusLine command, we record it to
6
- // PREV_STATUSLINE_PATH and the generated render script RUNS that prior command
7
- // (piping Claude Code's stdin to it) and prints its output FIRST, then prints the
8
- // ContextSpin snippet on its own line beneath. The user's statusline is composed
9
- // with ours, never discarded.
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.
10
23
 
11
24
  import fs from "node:fs";
12
25
  import fsp from "node:fs/promises";
@@ -29,10 +42,13 @@ import {
29
42
  * - Reads and BUFFERS all of stdin (Claude Code pipes a JSON payload). We must
30
43
  * consume it so the writer never gets EPIPE; we also feed it to a wrapped
31
44
  * prior statusline command (below).
32
- * - If PREV_STATUSLINE_PATH exists and names a command, spawns that command via
33
- * the shell, writes the buffered stdin to ITS stdin, captures its stdout with
34
- * a short timeout (killed on timeout), and prints that output VERBATIM first
35
- * (it may be multiple lines). Any failure here is swallowed.
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.
36
52
  * - Reads the cache (tolerating a missing file).
37
53
  * - Reads `cooldownAfterShown` from the config (fallback 3).
38
54
  * - Selects snippets where shownCount < cooldownAfterShown, picks the one with
@@ -43,13 +59,13 @@ import {
43
59
  * - Wraps EVERYTHING so any error still exits 0 with whatever output succeeded
44
60
  * (the prior statusline must never be lost and the bar must never break).
45
61
  *
46
- * The cache, config, and prev-statusline paths are baked into the script as
62
+ * The cache, config, and prev-statusline-map paths are baked into the script as
47
63
  * string literals so the generated file is fully self-contained with no imports
48
64
  * beyond node builtins.
49
65
  *
50
66
  * @param {string} cachePath - Absolute path to the snippet cache JSON file.
51
67
  * @param {string} configPath - Absolute path to the ContextSpin config JSON file.
52
- * @param {string} prevPath - Absolute path to the prev-statusline JSON file.
68
+ * @param {string} prevPath - Absolute path to the prev-statusline MAP JSON file.
53
69
  * @returns {string} The ESM source of the render script.
54
70
  */
55
71
  function buildRenderScript(cachePath, configPath, prevPath) {
@@ -57,8 +73,9 @@ function buildRenderScript(cachePath, configPath, prevPath) {
57
73
  const CONFIG = JSON.stringify(configPath);
58
74
  const PREV = JSON.stringify(prevPath);
59
75
  return `// contextspin statusline-render.js (generated) — composes any prior
60
- // statusline with one ContextSpin snippet line. MUST always exit 0 and never
61
- // lose the prior statusline's output, so the user's status bar never breaks.
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.
62
79
  import fs from "node:fs";
63
80
  import { spawn } from "node:child_process";
64
81
 
@@ -93,21 +110,76 @@ function readStdin() {
93
110
  });
94
111
  }
95
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
+
96
177
  /**
97
178
  * Run the recorded prior statusline command, feeding it the buffered stdin, and
98
179
  * resolve with its captured stdout (string). Swallows every failure -> "".
99
180
  */
100
- function runPrevStatusline(stdinBuf) {
181
+ function runPrevStatusline(command, stdinBuf) {
101
182
  return new Promise((resolve) => {
102
- let prev;
103
- try {
104
- const raw = fs.readFileSync(PREV_STATUSLINE_PATH, "utf8");
105
- prev = JSON.parse(raw);
106
- } catch {
107
- resolve("");
108
- return;
109
- }
110
- const command = prev && typeof prev.command === "string" ? prev.command : "";
111
183
  if (!command) {
112
184
  resolve("");
113
185
  return;
@@ -231,10 +303,13 @@ function writeOut(text) {
231
303
  async function main() {
232
304
  const stdinBuf = await readStdin();
233
305
 
234
- // (a) Prior statusline output FIRST (verbatim, possibly multi-line).
306
+ // (a) Prior statusline output FIRST (verbatim, possibly multi-line), looked up
307
+ // per-project from the PREV map.
235
308
  let prevOut = "";
236
309
  try {
237
- prevOut = await runPrevStatusline(stdinBuf);
310
+ const projectDir = projectDirFromStdin(stdinBuf);
311
+ const command = priorCommandFor(projectDir);
312
+ prevOut = await runPrevStatusline(command, stdinBuf);
238
313
  } catch {
239
314
  prevOut = "";
240
315
  }
@@ -279,6 +354,20 @@ async function readJsonSafe(filePath, fallback) {
279
354
  }
280
355
  }
281
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
+
282
371
  /**
283
372
  * Atomically write a pretty-printed JSON file (write tmp then rename).
284
373
  * @param {string} filePath
@@ -291,11 +380,64 @@ async function writeJsonAtomic(filePath, data) {
291
380
  await fsp.rename(tmp, filePath);
292
381
  }
293
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
+
294
435
  /**
295
436
  * @typedef {Object} InstallStatuslineResult
296
437
  * @property {string} statuslineSh - Path to the generated bash wrapper.
297
438
  * @property {string} statuslineJs - Path to the generated Node render script.
298
439
  * @property {string} settingsPath - Path to the patched Claude settings file.
440
+ * @property {"project"|"user"} scope - Whether we wrote project or user settings.
299
441
  * @property {boolean} backedUp - Whether an existing statusLine was backed up.
300
442
  * @property {boolean} composed - Whether we wrapped an existing statusline
301
443
  * (its output is composed above the ContextSpin line).
@@ -303,82 +445,121 @@ async function writeJsonAtomic(filePath, data) {
303
445
  */
304
446
 
305
447
  /**
306
- * Install the ContextSpin statusline integration (NON-DESTRUCTIVE):
307
- * - Writes the self-contained render script to STATUSLINE_JS.
308
- * - Writes an executable bash wrapper to STATUSLINE_SH that execs the render
309
- * script with stderr silenced.
310
- * - If an existing statusLine command (other than ours) is present, RECORDS it
311
- * to PREV_STATUSLINE_PATH (once — idempotent; never captures our own command)
312
- * so the render script can run it and prepend its output. Also backs up
313
- * settings.json to the .contextspin.bak once, as before.
314
- * - Patches ~/.claude/settings.json so `statusLine` points at our wrapper, with
315
- * `refreshInterval` in SECONDS (from config.injection.refresh).
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).
316
463
  *
317
464
  * @param {object} config - Normalized ContextSpin config (uses injection.refresh).
465
+ * @param {{ projectDir?: string }} [opts]
318
466
  * @returns {Promise<InstallStatuslineResult>}
319
467
  */
320
- export async function installStatusline(config) {
468
+ export async function installStatusline(config, opts = {}) {
321
469
  await fsp.mkdir(STATE_DIR, { recursive: true });
322
470
 
323
- // (1) Patch Claude settings first detect/record any existing statusline so
324
- // the generated render script can compose it. (We read settings before
325
- // writing the render script so a re-run never captures our own command.)
326
- await fsp.mkdir(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
327
- const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, {});
328
- const settingsObj = settings && typeof settings === "object" ? settings : {};
329
-
330
- let backedUp = false;
331
- let composed = false;
332
- let warning = null;
333
-
334
- const existing = settingsObj.statusLine;
335
- if (
336
- existing &&
337
- typeof existing === "object" &&
338
- existing.command &&
339
- existing.command !== STATUSLINE_SH
340
- ) {
341
- // NON-DESTRUCTIVE: record the prior command so we run it and prepend its
342
- // output. We're inside the `existing.command !== STATUSLINE_SH` branch, so
343
- // this never records our own wrapper. Refresh the record if the prior
344
- // command changed out-of-band (otherwise a stale prior would keep running).
345
- let recordedPrev = null;
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) {
346
477
  try {
347
- recordedPrev = JSON.parse(fs.readFileSync(PREV_STATUSLINE_PATH, "utf8"));
478
+ projectDir = fs.realpathSync(opts.projectDir);
348
479
  } catch {
349
- recordedPrev = null;
350
- }
351
- if (!recordedPrev || recordedPrev.command !== String(existing.command)) {
352
- await writeJsonAtomic(PREV_STATUSLINE_PATH, {
353
- command: String(existing.command),
354
- type: existing.type || "command",
355
- });
480
+ projectDir = path.resolve(opts.projectDir);
356
481
  }
357
- composed = true;
482
+ }
358
483
 
359
- const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
360
- if (!fs.existsSync(backupPath)) {
361
- await fsp.copyFile(CLAUDE_SETTINGS_PATH, backupPath);
362
- backedUp = true;
363
- }
364
- warning =
365
- `Existing statusLine command (\`${existing.command}\`) is preserved: ` +
366
- `ContextSpin runs it and shows its output above the ContextSpin line. ` +
367
- `A backup of your settings is at ${backupPath}. Run \`contextspin uninject\` to restore it.`;
368
- } else if (existing && typeof existing === "object" && existing.command === STATUSLINE_SH) {
369
- // Already ours: a prior command may have been recorded on a previous run.
370
- composed = fs.existsSync(PREV_STATUSLINE_PATH);
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);
371
525
  }
526
+ await writePrevMap(map);
372
527
 
373
- // (2) Render script (now knows the prev-statusline path).
528
+ // (3) Render script (knows the prev-statusline MAP path) + bash wrapper.
374
529
  const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH);
375
530
  await fsp.writeFile(STATUSLINE_JS, renderSource);
376
531
 
377
- // (3) Bash wrapper. Silence stderr so node warnings never reach the status bar.
532
+ // Silence stderr so node warnings never reach the status bar.
378
533
  const shSource = `#!/usr/bin/env bash\nexec node ${JSON.stringify(STATUSLINE_JS)} 2>/dev/null\n`;
379
534
  await fsp.writeFile(STATUSLINE_SH, shSource);
380
535
  await fsp.chmod(STATUSLINE_SH, 0o755);
381
536
 
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, {});
541
+ const settingsObj = settings && typeof settings === "object" ? settings : {};
542
+
543
+ let backedUp = false;
544
+ let warning = null;
545
+
546
+ // If TARGET already held a non-ours statusLine, back it up once.
547
+ const targetExisting = settingsObj.statusLine;
548
+ if (
549
+ targetExisted &&
550
+ targetExisting &&
551
+ typeof targetExisting === "object" &&
552
+ typeof targetExisting.command === "string" &&
553
+ targetExisting.command &&
554
+ targetExisting.command !== STATUSLINE_SH
555
+ ) {
556
+ const backupPath = targetPath + ".contextspin.bak";
557
+ if (!fs.existsSync(backupPath)) {
558
+ await fsp.copyFile(targetPath, backupPath);
559
+ backedUp = true;
560
+ }
561
+ }
562
+
382
563
  const refresh =
383
564
  config && config.injection && typeof config.injection.refresh === "number"
384
565
  ? config.injection.refresh
@@ -391,12 +572,24 @@ export async function installStatusline(config) {
391
572
  refreshInterval: refresh, // SECONDS
392
573
  };
393
574
 
394
- 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
+ }
395
587
 
396
588
  return {
397
589
  statuslineSh: STATUSLINE_SH,
398
590
  statuslineJs: STATUSLINE_JS,
399
- settingsPath: CLAUDE_SETTINGS_PATH,
591
+ settingsPath: targetPath,
592
+ scope,
400
593
  backedUp,
401
594
  composed,
402
595
  warning,
@@ -407,34 +600,75 @@ export async function installStatusline(config) {
407
600
  * @typedef {Object} UninstallStatuslineResult
408
601
  * @property {boolean} removed - Whether our statusLine entry was removed.
409
602
  * @property {boolean} restored - Whether settings were restored from backup.
410
- * @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.
411
605
  * @property {string|null} note - Human-readable note, or null.
412
606
  */
413
607
 
414
- /** Best-effort removal of the recorded prev-statusline file. */
415
- async function removePrevStatusline() {
608
+ /**
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) {
416
615
  try {
417
- await fsp.unlink(PREV_STATUSLINE_PATH);
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
+ }
418
625
  } catch {
419
- // best effort (may not exist)
626
+ // best effort
420
627
  }
421
628
  }
422
629
 
423
630
  /**
424
- * Uninstall the ContextSpin statusline integration. If the current
425
- * `statusLine.command` is ours, restore the `.contextspin.bak` backup when
426
- * present (which brings back the prior command), otherwise just drop the
427
- * `statusLine` key. Always removes the recorded prev-statusline file.
631
+ * Uninstall the ContextSpin statusline integration (SCOPE-AWARE reverse).
428
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.
639
+ *
640
+ * @param {{ projectDir?: string }} [opts]
429
641
  * @returns {Promise<UninstallStatuslineResult>}
430
642
  */
431
- export async function uninstallStatusline() {
432
- 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);
433
664
  if (!settings || typeof settings !== "object") {
665
+ // Nothing in TARGET, but still drop any recorded prev entry for this scope.
666
+ await removePrevEntry(key);
434
667
  return {
435
668
  removed: false,
436
669
  restored: false,
437
- settingsPath: CLAUDE_SETTINGS_PATH,
670
+ settingsPath: targetPath,
671
+ scope,
438
672
  note: "No Claude settings file found; nothing to uninstall.",
439
673
  };
440
674
  }
@@ -444,41 +678,46 @@ export async function uninstallStatusline() {
444
678
  current && typeof current === "object" && current.command === STATUSLINE_SH;
445
679
 
446
680
  if (!isOurs) {
681
+ await removePrevEntry(key);
447
682
  return {
448
683
  removed: false,
449
684
  restored: false,
450
- settingsPath: CLAUDE_SETTINGS_PATH,
685
+ settingsPath: targetPath,
686
+ scope,
451
687
  note: "statusLine is not managed by ContextSpin; left unchanged.",
452
688
  };
453
689
  }
454
690
 
455
- const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
691
+ const backupPath = targetPath + ".contextspin.bak";
456
692
  if (fs.existsSync(backupPath)) {
457
693
  const backup = await readJsonSafe(backupPath, null);
458
694
  if (backup && typeof backup === "object") {
459
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, backup);
695
+ await writeJsonAtomic(targetPath, backup);
460
696
  try {
461
697
  await fsp.unlink(backupPath);
462
698
  } catch {
463
699
  // best effort
464
700
  }
465
- await removePrevStatusline();
701
+ await removePrevEntry(key);
466
702
  return {
467
703
  removed: true,
468
704
  restored: true,
469
- settingsPath: CLAUDE_SETTINGS_PATH,
705
+ settingsPath: targetPath,
706
+ scope,
470
707
  note: "Restored previous Claude settings from backup.",
471
708
  };
472
709
  }
473
710
  }
474
711
 
712
+ // No backup: JSON-merge to delete just our statusLine key (preserve the rest).
475
713
  delete settings.statusLine;
476
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
477
- await removePrevStatusline();
714
+ await writeJsonAtomic(targetPath, settings);
715
+ await removePrevEntry(key);
478
716
  return {
479
717
  removed: true,
480
718
  restored: false,
481
- settingsPath: CLAUDE_SETTINGS_PATH,
719
+ settingsPath: targetPath,
720
+ scope,
482
721
  note: "Removed the ContextSpin statusLine entry.",
483
722
  };
484
723
  }