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