contextspin 0.2.0 → 0.4.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.4.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
 
@@ -53,6 +56,21 @@ export const CLAUDE_USER_CONFIG_PATH = path.join(HOME, ".claude.json");
53
56
  /** Suffix appended to a Claude install path to name its patcher backup. */
54
57
  export const PATCHER_BACKUP_SUFFIX = ".contextspin.backup";
55
58
 
59
+ /**
60
+ * Built-in "prefilled" snippet texts. The statusline render script falls back to
61
+ * these (rotating through them) whenever there is no live snippet to show — so
62
+ * the status bar is NEVER empty, even immediately after install before the daemon
63
+ * has fetched anything, or when every source returns nothing. They double as
64
+ * onboarding hints pointing at the next useful thing to configure.
65
+ */
66
+ export const DEFAULT_SNIPPETS = [
67
+ "✨ ContextSpin is live — real-time context, right in your statusline",
68
+ "📅 Add a calendar source to see your next meeting here",
69
+ "👀 Add a GitHub source to surface PRs awaiting your review",
70
+ "🌤️ Add the weather starter source for local conditions at a glance",
71
+ "🛠️ Run the contextspin-setup skill to wire up more sources",
72
+ ];
73
+
56
74
  /** Default top-level config sections. */
57
75
  export const DEFAULTS = {
58
76
  injection: { mode: "statusline", refresh: 30, maxVisible: 5 },
@@ -148,6 +166,7 @@ export function defaultConfig(sources) {
148
166
  deduplication: true,
149
167
  cooldownAfterShown: 3,
150
168
  priorityOrder: [
169
+ "review",
151
170
  "incident",
152
171
  "ci",
153
172
  "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";
@@ -19,6 +32,7 @@ import {
19
32
  CACHE_PATH,
20
33
  CONFIG_PATH,
21
34
  CLAUDE_SETTINGS_PATH,
35
+ DEFAULT_SNIPPETS,
22
36
  } from "../config.js";
23
37
 
24
38
  /**
@@ -29,10 +43,13 @@ import {
29
43
  * - Reads and BUFFERS all of stdin (Claude Code pipes a JSON payload). We must
30
44
  * consume it so the writer never gets EPIPE; we also feed it to a wrapped
31
45
  * 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.
46
+ * - Tolerantly JSON-parses the buffered stdin to find the project dir (trying
47
+ * workspace.project_dir, then workspace.current_dir, then cwd), then looks up
48
+ * the prior command in the PREV map by that dir, falling back to the user ("")
49
+ * entry. If a prior command is found, it spawns that command via the shell,
50
+ * writes the buffered stdin to ITS stdin, captures its stdout with a 2000ms
51
+ * timeout (SIGKILL on timeout), and prints that output VERBATIM first (it may
52
+ * be multiple lines). Any failure here is swallowed.
36
53
  * - Reads the cache (tolerating a missing file).
37
54
  * - Reads `cooldownAfterShown` from the config (fallback 3).
38
55
  * - Selects snippets where shownCount < cooldownAfterShown, picks the one with
@@ -43,28 +60,31 @@ import {
43
60
  * - Wraps EVERYTHING so any error still exits 0 with whatever output succeeded
44
61
  * (the prior statusline must never be lost and the bar must never break).
45
62
  *
46
- * The cache, config, and prev-statusline paths are baked into the script as
63
+ * The cache, config, and prev-statusline-map paths are baked into the script as
47
64
  * string literals so the generated file is fully self-contained with no imports
48
65
  * beyond node builtins.
49
66
  *
50
67
  * @param {string} cachePath - Absolute path to the snippet cache JSON file.
51
68
  * @param {string} configPath - Absolute path to the ContextSpin config JSON file.
52
- * @param {string} prevPath - Absolute path to the prev-statusline JSON file.
69
+ * @param {string} prevPath - Absolute path to the prev-statusline MAP JSON file.
53
70
  * @returns {string} The ESM source of the render script.
54
71
  */
55
72
  function buildRenderScript(cachePath, configPath, prevPath) {
56
73
  const CACHE = JSON.stringify(cachePath);
57
74
  const CONFIG = JSON.stringify(configPath);
58
75
  const PREV = JSON.stringify(prevPath);
76
+ const DEFAULTS = JSON.stringify(DEFAULT_SNIPPETS);
59
77
  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.
78
+ // statusline (looked up per-project) with one ContextSpin snippet line. MUST
79
+ // always exit 0 and never lose the prior statusline's output, so the user's
80
+ // status bar never breaks.
62
81
  import fs from "node:fs";
63
82
  import { spawn } from "node:child_process";
64
83
 
65
84
  const CACHE_PATH = ${CACHE};
66
85
  const CONFIG_PATH = ${CONFIG};
67
86
  const PREV_STATUSLINE_PATH = ${PREV};
87
+ const DEFAULT_SNIPPETS = ${DEFAULTS};
68
88
 
69
89
  /** Buffer ALL of stdin into a Buffer. Resolves on end/close/error/timeout. */
70
90
  function readStdin() {
@@ -93,21 +113,76 @@ function readStdin() {
93
113
  });
94
114
  }
95
115
 
116
+ /**
117
+ * Tolerantly parse the buffered stdin payload for the project dir. Tries
118
+ * workspace.project_dir, then workspace.current_dir, then cwd. Returns "" on any
119
+ * failure (which falls back to the user-scope prev entry).
120
+ */
121
+ function projectDirFromStdin(stdinBuf) {
122
+ try {
123
+ const payload = JSON.parse(stdinBuf.toString("utf8"));
124
+ const ws = payload && typeof payload.workspace === "object" ? payload.workspace : {};
125
+ const dir =
126
+ (ws && ws.project_dir) ||
127
+ (ws && ws.current_dir) ||
128
+ (payload && payload.cwd) ||
129
+ "";
130
+ return typeof dir === "string" ? dir : "";
131
+ } catch {
132
+ return "";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Read the prev-statusline MAP (keyed by absolute project dir, with "" for the
138
+ * user scope). Tolerates a missing/old file. An OLD single-object file (one with
139
+ * a top-level \`command\` field) is migrated in-memory to the "" (user) entry.
140
+ * Returns an object map (possibly empty); never throws.
141
+ */
142
+ function readPrevMap() {
143
+ let raw;
144
+ try {
145
+ raw = JSON.parse(fs.readFileSync(PREV_STATUSLINE_PATH, "utf8"));
146
+ } catch {
147
+ return {};
148
+ }
149
+ if (!raw || typeof raw !== "object") return {};
150
+ // Migrate an old single-object record to the user ("") entry.
151
+ if (typeof raw.command === "string") {
152
+ return { "": { command: raw.command, type: raw.type || "command" } };
153
+ }
154
+ return raw;
155
+ }
156
+
157
+ /**
158
+ * Resolve the prior statusline command for a given project dir from the map,
159
+ * falling back to the user ("") entry. Returns "" when none is recorded.
160
+ */
161
+ function priorCommandFor(projectDir) {
162
+ const map = readPrevMap();
163
+ // Try the raw dir, then its realpath (the install side keys by realpath, so a
164
+ // symlinked root still matches), then fall back to the user ("") entry.
165
+ const candidates = [];
166
+ if (projectDir) {
167
+ candidates.push(projectDir);
168
+ try { candidates.push(fs.realpathSync(projectDir)); } catch {}
169
+ }
170
+ candidates.push("");
171
+ for (const k of candidates) {
172
+ const entry = map[k];
173
+ if (entry && typeof entry === "object" && typeof entry.command === "string") {
174
+ return entry.command;
175
+ }
176
+ }
177
+ return "";
178
+ }
179
+
96
180
  /**
97
181
  * Run the recorded prior statusline command, feeding it the buffered stdin, and
98
182
  * resolve with its captured stdout (string). Swallows every failure -> "".
99
183
  */
100
- function runPrevStatusline(stdinBuf) {
184
+ function runPrevStatusline(command, stdinBuf) {
101
185
  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
186
  if (!command) {
112
187
  resolve("");
113
188
  return;
@@ -171,16 +246,41 @@ function writeJsonAtomic(filePath, data) {
171
246
  fs.renameSync(tmp, filePath);
172
247
  }
173
248
 
174
- /** Compute the ContextSpin snippet line (may be ""); bumps shownCount. */
249
+ /**
250
+ * Pick a rotating built-in default snippet text. NEVER exhausts (defaults are
251
+ * always available) — this is the guarantee that the status bar is never empty.
252
+ * Persists a rotating index back into the cache object (caller writes it).
253
+ */
254
+ function defaultLine(cache) {
255
+ if (!Array.isArray(DEFAULT_SNIPPETS) || DEFAULT_SNIPPETS.length === 0) return "";
256
+ const n = DEFAULT_SNIPPETS.length;
257
+ const idx = Number.isInteger(cache && cache._defaultIndex) ? cache._defaultIndex : 0;
258
+ const text = DEFAULT_SNIPPETS[((idx % n) + n) % n];
259
+ if (cache && typeof cache === "object") {
260
+ cache._defaultIndex = (idx + 1) % n;
261
+ try {
262
+ writeJsonAtomic(CACHE_PATH, cache);
263
+ } catch {
264
+ // best effort — still show the default this render
265
+ }
266
+ }
267
+ return String(text).replace(/\\r?\\n/g, " ");
268
+ }
269
+
270
+ /**
271
+ * Compute the ContextSpin snippet line. ALWAYS returns a non-empty string: a
272
+ * live snippet when one is eligible, otherwise a rotating built-in default so the
273
+ * status bar is never blank.
274
+ */
175
275
  function contextSpinLine() {
176
276
  let cache;
177
277
  try {
178
278
  cache = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
179
279
  } catch {
180
- return "";
280
+ cache = {};
181
281
  }
182
- const snippets = Array.isArray(cache && cache.snippets) ? cache.snippets : [];
183
- if (snippets.length === 0) return "";
282
+ if (!cache || typeof cache !== "object") cache = {};
283
+ const snippets = Array.isArray(cache.snippets) ? cache.snippets : [];
184
284
 
185
285
  // cooldownAfterShown from config (fallback 3).
186
286
  let cooldownAfterShown = 3;
@@ -195,7 +295,8 @@ function contextSpinLine() {
195
295
  const eligible = snippets.filter(
196
296
  (s) => s && typeof s.text === "string" && (s.shownCount || 0) < cooldownAfterShown
197
297
  );
198
- if (eligible.length === 0) return "";
298
+ // No live snippet to show -> fall back to a rotating built-in default.
299
+ if (eligible.length === 0) return defaultLine(cache);
199
300
 
200
301
  eligible.sort((a, b) => {
201
302
  const ca = a.shownCount || 0;
@@ -217,6 +318,28 @@ function contextSpinLine() {
217
318
  return String(chosen.text).replace(/\\r?\\n/g, " ");
218
319
  }
219
320
 
321
+ /**
322
+ * Wrap the ContextSpin line in a compact, "boxed" ANSI style — bright italic
323
+ * text between cyan bars — so it stands out from the prior statusline. Honors
324
+ * \`injection.style: false\` in the config to opt out (plain text). Any error
325
+ * falls back to the plain text.
326
+ */
327
+ function styleLine(text) {
328
+ if (!text) return text;
329
+ let enabled = true;
330
+ try {
331
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
332
+ if (cfg && cfg.injection && cfg.injection.style === false) enabled = false;
333
+ } catch {
334
+ // keep enabled
335
+ }
336
+ if (!enabled) return text;
337
+ const BAR = "\\x1b[36m"; // cyan
338
+ const BODY = "\\x1b[3;96m"; // italic + bright cyan
339
+ const RESET = "\\x1b[0m";
340
+ return BAR + "┃" + RESET + " " + BODY + text + RESET + " " + BAR + "┃" + RESET;
341
+ }
342
+
220
343
  /** Write a string to stdout, awaiting the flush callback. */
221
344
  function writeOut(text) {
222
345
  return new Promise((resolve) => {
@@ -231,18 +354,21 @@ function writeOut(text) {
231
354
  async function main() {
232
355
  const stdinBuf = await readStdin();
233
356
 
234
- // (a) Prior statusline output FIRST (verbatim, possibly multi-line).
357
+ // (a) Prior statusline output FIRST (verbatim, possibly multi-line), looked up
358
+ // per-project from the PREV map.
235
359
  let prevOut = "";
236
360
  try {
237
- prevOut = await runPrevStatusline(stdinBuf);
361
+ const projectDir = projectDirFromStdin(stdinBuf);
362
+ const command = priorCommandFor(projectDir);
363
+ prevOut = await runPrevStatusline(command, stdinBuf);
238
364
  } catch {
239
365
  prevOut = "";
240
366
  }
241
367
 
242
- // (b) ContextSpin snippet line.
368
+ // (b) ContextSpin snippet line (always non-empty; styled).
243
369
  let line = "";
244
370
  try {
245
- line = contextSpinLine();
371
+ line = styleLine(contextSpinLine());
246
372
  } catch {
247
373
  line = "";
248
374
  }
@@ -279,6 +405,20 @@ async function readJsonSafe(filePath, fallback) {
279
405
  }
280
406
  }
281
407
 
408
+ /**
409
+ * Synchronous JSON read returning a fallback on any read/parse error.
410
+ * @param {string} filePath
411
+ * @param {*} fallback
412
+ * @returns {*}
413
+ */
414
+ function readJsonSafeSync(filePath, fallback) {
415
+ try {
416
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
417
+ } catch {
418
+ return fallback;
419
+ }
420
+ }
421
+
282
422
  /**
283
423
  * Atomically write a pretty-printed JSON file (write tmp then rename).
284
424
  * @param {string} filePath
@@ -291,11 +431,64 @@ async function writeJsonAtomic(filePath, data) {
291
431
  await fsp.rename(tmp, filePath);
292
432
  }
293
433
 
434
+ /**
435
+ * Read the prev-statusline MAP from disk: an object keyed by absolute project
436
+ * dir (with "" reserved for the user scope), each value { command, type }.
437
+ *
438
+ * Tolerates a missing/unparseable file (-> {}). Migrates an OLD single-object
439
+ * file (one with a top-level `command` field) by treating it as the "" (user)
440
+ * entry. Never throws.
441
+ *
442
+ * @returns {Record<string, {command: string, type: string}>}
443
+ */
444
+ function readPrevMap() {
445
+ const raw = readJsonSafeSync(PREV_STATUSLINE_PATH, null);
446
+ if (!raw || typeof raw !== "object") return {};
447
+ // Migrate an old single-object record to the user ("") entry.
448
+ if (typeof raw.command === "string") {
449
+ return { "": { command: raw.command, type: raw.type || "command" } };
450
+ }
451
+ return raw;
452
+ }
453
+
454
+ /**
455
+ * Persist the prev-statusline MAP atomically.
456
+ * @param {Record<string, {command: string, type: string}>} map
457
+ * @returns {Promise<void>}
458
+ */
459
+ async function writePrevMap(map) {
460
+ await writeJsonAtomic(PREV_STATUSLINE_PATH, map);
461
+ }
462
+
463
+ /**
464
+ * Resolve the statusLine command currently configured in a settings file (if
465
+ * any), ignoring our own wrapper. Returns null when the file has no usable
466
+ * non-ours statusLine command.
467
+ *
468
+ * @param {string} settingsPath
469
+ * @returns {{command: string, type: string}|null}
470
+ */
471
+ function priorFromSettings(settingsPath) {
472
+ const settings = readJsonSafeSync(settingsPath, null);
473
+ const sl = settings && typeof settings === "object" ? settings.statusLine : null;
474
+ if (
475
+ sl &&
476
+ typeof sl === "object" &&
477
+ typeof sl.command === "string" &&
478
+ sl.command &&
479
+ sl.command !== STATUSLINE_SH
480
+ ) {
481
+ return { command: sl.command, type: sl.type || "command" };
482
+ }
483
+ return null;
484
+ }
485
+
294
486
  /**
295
487
  * @typedef {Object} InstallStatuslineResult
296
488
  * @property {string} statuslineSh - Path to the generated bash wrapper.
297
489
  * @property {string} statuslineJs - Path to the generated Node render script.
298
490
  * @property {string} settingsPath - Path to the patched Claude settings file.
491
+ * @property {"project"|"user"} scope - Whether we wrote project or user settings.
299
492
  * @property {boolean} backedUp - Whether an existing statusLine was backed up.
300
493
  * @property {boolean} composed - Whether we wrapped an existing statusline
301
494
  * (its output is composed above the ContextSpin line).
@@ -303,82 +496,121 @@ async function writeJsonAtomic(filePath, data) {
303
496
  */
304
497
 
305
498
  /**
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).
499
+ * Install the ContextSpin statusline integration (SCOPE-AWARE, NON-DESTRUCTIVE).
500
+ *
501
+ * TARGET settings file + PREV map KEY:
502
+ * - If opts.projectDir is set: TARGET = <projectDir>/.claude/settings.local.json
503
+ * (gitignored, outranks the tracked settings.json); KEY = the resolved
504
+ * absolute projectDir.
505
+ * - Else: TARGET = the user ~/.claude/settings.json; KEY = "" (user scope).
506
+ *
507
+ * PRIOR detection (the statusline currently effective and NOT ours, to compose):
508
+ * - If projectDir set: the project's tracked .claude/settings.json statusLine if
509
+ * present and not ours; else the user settings.json statusLine if not ours;
510
+ * else none.
511
+ * - Else: the user settings.json statusLine if present and not ours; else none.
512
+ * We never treat our own STATUSLINE_SH as a prior, and record the detected prior
513
+ * into the PREV map under KEY (refreshing it if it differs).
316
514
  *
317
515
  * @param {object} config - Normalized ContextSpin config (uses injection.refresh).
516
+ * @param {{ projectDir?: string }} [opts]
318
517
  * @returns {Promise<InstallStatuslineResult>}
319
518
  */
320
- export async function installStatusline(config) {
519
+ export async function installStatusline(config, opts = {}) {
321
520
  await fsp.mkdir(STATE_DIR, { recursive: true });
322
521
 
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;
522
+ // Canonicalize with realpath so the PREV-map key matches whatever the render
523
+ // script derives from Claude Code's stdin (symlinked roots like macOS /var vs
524
+ // /private/var would otherwise diverge). Fall back to path.resolve if realpath
525
+ // throws (e.g. the dir does not exist yet).
526
+ let projectDir = null;
527
+ if (opts && typeof opts.projectDir === "string" && opts.projectDir) {
346
528
  try {
347
- recordedPrev = JSON.parse(fs.readFileSync(PREV_STATUSLINE_PATH, "utf8"));
529
+ projectDir = fs.realpathSync(opts.projectDir);
348
530
  } 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
- });
531
+ projectDir = path.resolve(opts.projectDir);
356
532
  }
357
- composed = true;
533
+ }
358
534
 
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);
535
+ // Resolve TARGET settings file + PREV-map KEY by scope.
536
+ const scope = projectDir ? "project" : "user";
537
+ const targetPath = projectDir
538
+ ? path.join(projectDir, ".claude", "settings.local.json")
539
+ : CLAUDE_SETTINGS_PATH;
540
+ const key = projectDir || "";
541
+
542
+ // (1) PRIOR detection. We look at the *currently effective* non-ours
543
+ // statusLine so the render script can compose it.
544
+ let prior = null;
545
+ if (projectDir) {
546
+ // The tracked project settings.json (which currently shadows us), then fall
547
+ // back to the user settings.json.
548
+ prior =
549
+ priorFromSettings(path.join(projectDir, ".claude", "settings.json")) ||
550
+ priorFromSettings(CLAUDE_SETTINGS_PATH);
551
+ } else {
552
+ prior = priorFromSettings(CLAUDE_SETTINGS_PATH);
371
553
  }
372
554
 
373
- // (2) Render script (now knows the prev-statusline path).
555
+ // (2) Record the prior into the PREV map under KEY. Refresh the entry if it
556
+ // differs; never record our own STATUSLINE_SH (priorFromSettings already
557
+ // excludes it). When there is no prior, drop any stale entry for this KEY.
558
+ const map = readPrevMap();
559
+ let composed = false;
560
+ if (prior && prior.command && prior.command !== STATUSLINE_SH) {
561
+ // Detected a real prior at this scope -> record/refresh it, so a repo that
562
+ // later changes its own statusLine is picked up on the next `ensure`.
563
+ map[key] = { command: prior.command, type: prior.type || "command" };
564
+ composed = true;
565
+ } else if (scope === "project") {
566
+ // Project priors come from the TRACKED settings.json, which we never write,
567
+ // so "no prior" means the repo genuinely has no statusLine -> drop any stale
568
+ // entry rather than keep running a command the repo has since removed.
569
+ if (map[key]) delete map[key];
570
+ composed = false;
571
+ } else {
572
+ // User scope: the prior source IS the file we overwrite with our wrapper, so
573
+ // "no prior" usually just means our wrapper is already installed. Keep any
574
+ // previously-recorded original prior.
575
+ composed = !!(map[key] && map[key].command);
576
+ }
577
+ await writePrevMap(map);
578
+
579
+ // (3) Render script (knows the prev-statusline MAP path) + bash wrapper.
374
580
  const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH);
375
581
  await fsp.writeFile(STATUSLINE_JS, renderSource);
376
582
 
377
- // (3) Bash wrapper. Silence stderr so node warnings never reach the status bar.
583
+ // Silence stderr so node warnings never reach the status bar.
378
584
  const shSource = `#!/usr/bin/env bash\nexec node ${JSON.stringify(STATUSLINE_JS)} 2>/dev/null\n`;
379
585
  await fsp.writeFile(STATUSLINE_SH, shSource);
380
586
  await fsp.chmod(STATUSLINE_SH, 0o755);
381
587
 
588
+ // (4) JSON-MERGE our statusLine into TARGET, preserving every other key.
589
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
590
+ const targetExisted = fs.existsSync(targetPath);
591
+ const settings = await readJsonSafe(targetPath, {});
592
+ const settingsObj = settings && typeof settings === "object" ? settings : {};
593
+
594
+ let backedUp = false;
595
+ let warning = null;
596
+
597
+ // If TARGET already held a non-ours statusLine, back it up once.
598
+ const targetExisting = settingsObj.statusLine;
599
+ if (
600
+ targetExisted &&
601
+ targetExisting &&
602
+ typeof targetExisting === "object" &&
603
+ typeof targetExisting.command === "string" &&
604
+ targetExisting.command &&
605
+ targetExisting.command !== STATUSLINE_SH
606
+ ) {
607
+ const backupPath = targetPath + ".contextspin.bak";
608
+ if (!fs.existsSync(backupPath)) {
609
+ await fsp.copyFile(targetPath, backupPath);
610
+ backedUp = true;
611
+ }
612
+ }
613
+
382
614
  const refresh =
383
615
  config && config.injection && typeof config.injection.refresh === "number"
384
616
  ? config.injection.refresh
@@ -391,12 +623,24 @@ export async function installStatusline(config) {
391
623
  refreshInterval: refresh, // SECONDS
392
624
  };
393
625
 
394
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settingsObj);
626
+ await writeJsonAtomic(targetPath, settingsObj);
627
+
628
+ if (composed) {
629
+ const priorCmd = prior ? prior.command : (map[key] && map[key].command);
630
+ warning =
631
+ `Existing statusLine command (\`${priorCmd}\`) is preserved: ` +
632
+ `ContextSpin runs it and shows its output above the ContextSpin line. ` +
633
+ (scope === "project"
634
+ ? `Wired into ${targetPath} (gitignored; outranks the tracked settings.json). `
635
+ : ``) +
636
+ `Run \`contextspin uninject\` to restore it.`;
637
+ }
395
638
 
396
639
  return {
397
640
  statuslineSh: STATUSLINE_SH,
398
641
  statuslineJs: STATUSLINE_JS,
399
- settingsPath: CLAUDE_SETTINGS_PATH,
642
+ settingsPath: targetPath,
643
+ scope,
400
644
  backedUp,
401
645
  composed,
402
646
  warning,
@@ -407,34 +651,75 @@ export async function installStatusline(config) {
407
651
  * @typedef {Object} UninstallStatuslineResult
408
652
  * @property {boolean} removed - Whether our statusLine entry was removed.
409
653
  * @property {boolean} restored - Whether settings were restored from backup.
410
- * @property {string} settingsPath - Path to the Claude settings file.
654
+ * @property {string} settingsPath - Path to the Claude settings file operated on.
655
+ * @property {"project"|"user"} scope - Which scope was operated on.
411
656
  * @property {string|null} note - Human-readable note, or null.
412
657
  */
413
658
 
414
- /** Best-effort removal of the recorded prev-statusline file. */
415
- async function removePrevStatusline() {
659
+ /**
660
+ * Remove a scope's entry from the prev-statusline MAP (best-effort). When the
661
+ * map becomes empty the file is removed; otherwise it is rewritten.
662
+ * @param {string} key - The PREV-map key ("" for user scope, else absolute dir).
663
+ * @returns {Promise<void>}
664
+ */
665
+ async function removePrevEntry(key) {
416
666
  try {
417
- await fsp.unlink(PREV_STATUSLINE_PATH);
667
+ const map = readPrevMap();
668
+ if (Object.prototype.hasOwnProperty.call(map, key)) {
669
+ delete map[key];
670
+ }
671
+ if (Object.keys(map).length === 0) {
672
+ await fsp.unlink(PREV_STATUSLINE_PATH).catch(() => {});
673
+ } else {
674
+ await writePrevMap(map);
675
+ }
418
676
  } catch {
419
- // best effort (may not exist)
677
+ // best effort
420
678
  }
421
679
  }
422
680
 
423
681
  /**
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.
682
+ * Uninstall the ContextSpin statusline integration (SCOPE-AWARE reverse).
683
+ *
684
+ * - Project scope (opts.projectDir set): operate on
685
+ * <projectDir>/.claude/settings.local.json. If a `.contextspin.bak` exists,
686
+ * restore it; else JSON-merge to delete just the `statusLine` key (preserving
687
+ * other keys). Remove that project's entry from the PREV map.
688
+ * - User scope: operate on the user ~/.claude/settings.json the same way and
689
+ * remove the "" (user) PREV entry.
428
690
  *
691
+ * @param {{ projectDir?: string }} [opts]
429
692
  * @returns {Promise<UninstallStatuslineResult>}
430
693
  */
431
- export async function uninstallStatusline() {
432
- const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, null);
694
+ export async function uninstallStatusline(opts = {}) {
695
+ // Canonicalize with realpath so the PREV-map key matches whatever the render
696
+ // script derives from Claude Code's stdin (symlinked roots like macOS /var vs
697
+ // /private/var would otherwise diverge). Fall back to path.resolve if realpath
698
+ // throws (e.g. the dir does not exist yet).
699
+ let projectDir = null;
700
+ if (opts && typeof opts.projectDir === "string" && opts.projectDir) {
701
+ try {
702
+ projectDir = fs.realpathSync(opts.projectDir);
703
+ } catch {
704
+ projectDir = path.resolve(opts.projectDir);
705
+ }
706
+ }
707
+
708
+ const scope = projectDir ? "project" : "user";
709
+ const targetPath = projectDir
710
+ ? path.join(projectDir, ".claude", "settings.local.json")
711
+ : CLAUDE_SETTINGS_PATH;
712
+ const key = projectDir || "";
713
+
714
+ const settings = await readJsonSafe(targetPath, null);
433
715
  if (!settings || typeof settings !== "object") {
716
+ // Nothing in TARGET, but still drop any recorded prev entry for this scope.
717
+ await removePrevEntry(key);
434
718
  return {
435
719
  removed: false,
436
720
  restored: false,
437
- settingsPath: CLAUDE_SETTINGS_PATH,
721
+ settingsPath: targetPath,
722
+ scope,
438
723
  note: "No Claude settings file found; nothing to uninstall.",
439
724
  };
440
725
  }
@@ -444,41 +729,46 @@ export async function uninstallStatusline() {
444
729
  current && typeof current === "object" && current.command === STATUSLINE_SH;
445
730
 
446
731
  if (!isOurs) {
732
+ await removePrevEntry(key);
447
733
  return {
448
734
  removed: false,
449
735
  restored: false,
450
- settingsPath: CLAUDE_SETTINGS_PATH,
736
+ settingsPath: targetPath,
737
+ scope,
451
738
  note: "statusLine is not managed by ContextSpin; left unchanged.",
452
739
  };
453
740
  }
454
741
 
455
- const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
742
+ const backupPath = targetPath + ".contextspin.bak";
456
743
  if (fs.existsSync(backupPath)) {
457
744
  const backup = await readJsonSafe(backupPath, null);
458
745
  if (backup && typeof backup === "object") {
459
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, backup);
746
+ await writeJsonAtomic(targetPath, backup);
460
747
  try {
461
748
  await fsp.unlink(backupPath);
462
749
  } catch {
463
750
  // best effort
464
751
  }
465
- await removePrevStatusline();
752
+ await removePrevEntry(key);
466
753
  return {
467
754
  removed: true,
468
755
  restored: true,
469
- settingsPath: CLAUDE_SETTINGS_PATH,
756
+ settingsPath: targetPath,
757
+ scope,
470
758
  note: "Restored previous Claude settings from backup.",
471
759
  };
472
760
  }
473
761
  }
474
762
 
763
+ // No backup: JSON-merge to delete just our statusLine key (preserve the rest).
475
764
  delete settings.statusLine;
476
- await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
477
- await removePrevStatusline();
765
+ await writeJsonAtomic(targetPath, settings);
766
+ await removePrevEntry(key);
478
767
  return {
479
768
  removed: true,
480
769
  restored: false,
481
- settingsPath: CLAUDE_SETTINGS_PATH,
770
+ settingsPath: targetPath,
771
+ scope,
482
772
  note: "Removed the ContextSpin statusLine entry.",
483
773
  };
484
774
  }