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.
- package/.contextspin.example.json +4 -54
- package/package.json +1 -1
- package/src/cli.js +52 -12
- package/src/config.js +22 -3
- package/src/detect.js +48 -71
- package/src/inject/statusline.js +402 -112
|
@@ -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.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
|
-
*
|
|
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
|
|
|
@@ -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
|
|
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";
|
|
@@ -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
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
61
|
-
// lose the prior statusline's output, so the user's
|
|
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
|
-
/**
|
|
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
|
-
|
|
280
|
+
cache = {};
|
|
181
281
|
}
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
|
|
529
|
+
projectDir = fs.realpathSync(opts.projectDir);
|
|
348
530
|
} 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
|
-
});
|
|
531
|
+
projectDir = path.resolve(opts.projectDir);
|
|
356
532
|
}
|
|
357
|
-
|
|
533
|
+
}
|
|
358
534
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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)
|
|
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
|
-
//
|
|
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(
|
|
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:
|
|
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
|
-
/**
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
677
|
+
// best effort
|
|
420
678
|
}
|
|
421
679
|
}
|
|
422
680
|
|
|
423
681
|
/**
|
|
424
|
-
* Uninstall the ContextSpin statusline integration
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
|
752
|
+
await removePrevEntry(key);
|
|
466
753
|
return {
|
|
467
754
|
removed: true,
|
|
468
755
|
restored: true,
|
|
469
|
-
settingsPath:
|
|
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(
|
|
477
|
-
await
|
|
765
|
+
await writeJsonAtomic(targetPath, settings);
|
|
766
|
+
await removePrevEntry(key);
|
|
478
767
|
return {
|
|
479
768
|
removed: true,
|
|
480
769
|
restored: false,
|
|
481
|
-
settingsPath:
|
|
770
|
+
settingsPath: targetPath,
|
|
771
|
+
scope,
|
|
482
772
|
note: "Removed the ContextSpin statusLine entry.",
|
|
483
773
|
};
|
|
484
774
|
}
|