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.
- package/.contextspin.example.json +4 -54
- package/package.json +1 -1
- package/src/cli.js +52 -12
- package/src/config.js +7 -3
- package/src/detect.js +48 -71
- package/src/inject/statusline.js +344 -105
|
@@ -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
|
|
17
|
-
"format": "
|
|
18
|
-
"label": "
|
|
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.
|
|
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
|
-
*
|
|
188
|
-
*
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
42
|
-
* existing statusline so we can run it and prepend its output). Holds
|
|
43
|
-
*
|
|
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
|
|
1
|
+
// src/detect.js — best-effort, zero-network detection of the single starter source.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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
|
|
71
|
-
function
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
95
|
-
function
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
121
|
-
* non-empty array
|
|
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
|
|
130
|
-
const [gh, glab
|
|
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
|
|
137
|
-
if (glab) return
|
|
113
|
+
if (gh) return [ghSource()];
|
|
114
|
+
if (glab) return [glabSource()];
|
|
138
115
|
|
|
139
|
-
// Neither present: return the gh
|
|
116
|
+
// Neither present: return the gh source as a graceful, gracefully-failing
|
|
140
117
|
// placeholder the user can edit.
|
|
141
|
-
return
|
|
118
|
+
return [ghSource()];
|
|
142
119
|
}
|
|
143
120
|
|
|
144
121
|
export default detectSources;
|
package/src/inject/statusline.js
CHANGED
|
@@ -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
|
|
3
|
+
// wires it into a Claude Code settings file under the camelCase `statusLine` key.
|
|
4
4
|
//
|
|
5
|
-
// NON-DESTRUCTIVE:
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
61
|
-
// lose the prior statusline's output, so the user's
|
|
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
|
-
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
478
|
+
projectDir = fs.realpathSync(opts.projectDir);
|
|
348
479
|
} catch {
|
|
349
|
-
|
|
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
|
-
|
|
482
|
+
}
|
|
358
483
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
// (
|
|
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
|
-
//
|
|
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(
|
|
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:
|
|
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
|
-
/**
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
626
|
+
// best effort
|
|
420
627
|
}
|
|
421
628
|
}
|
|
422
629
|
|
|
423
630
|
/**
|
|
424
|
-
* Uninstall the ContextSpin statusline integration
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
|
701
|
+
await removePrevEntry(key);
|
|
466
702
|
return {
|
|
467
703
|
removed: true,
|
|
468
704
|
restored: true,
|
|
469
|
-
settingsPath:
|
|
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(
|
|
477
|
-
await
|
|
714
|
+
await writeJsonAtomic(targetPath, settings);
|
|
715
|
+
await removePrevEntry(key);
|
|
478
716
|
return {
|
|
479
717
|
removed: true,
|
|
480
718
|
restored: false,
|
|
481
|
-
settingsPath:
|
|
719
|
+
settingsPath: targetPath,
|
|
720
|
+
scope,
|
|
482
721
|
note: "Removed the ContextSpin statusLine entry.",
|
|
483
722
|
};
|
|
484
723
|
}
|