contextspin 0.1.2 → 0.2.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/package.json +1 -1
- package/src/cli.js +97 -47
- package/src/config.js +42 -2
- package/src/daemon.js +4 -1
- package/src/detect.js +144 -0
- package/src/inject/statusline.js +226 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contextspin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,77 @@ async function runSetup(opts = {}) {
|
|
|
211
183
|
}
|
|
212
184
|
}
|
|
213
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Whether the Claude Code statusLine is already pointing at our wrapper.
|
|
188
|
+
* Best-effort: any read/parse/missing-file error -> false.
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
function statuslineIsOurs() {
|
|
192
|
+
try {
|
|
193
|
+
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) return false;
|
|
194
|
+
const parsed = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
|
|
195
|
+
const sl = parsed && parsed.statusLine;
|
|
196
|
+
return !!(sl && typeof sl === 'object' && sl.command === STATUSLINE_SH);
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The ENSURE flow: idempotent, non-interactive, safe to run every session
|
|
204
|
+
* (this is what the plugin SessionStart hook invokes). It:
|
|
205
|
+
* (a) creates a detected config if none exists,
|
|
206
|
+
* (b) wires the statusline if the mode is statusline/both and it is not
|
|
207
|
+
* already pointing at our wrapper, and
|
|
208
|
+
* (c) starts the daemon if it is not already running.
|
|
209
|
+
* Prints a concise one-line summary. Never throws on the normal paths; any
|
|
210
|
+
* error prints a clean line and the process still exits 0 (the hook depends on
|
|
211
|
+
* this — a non-zero exit would surface an error to the user every session).
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
*/
|
|
214
|
+
async function runEnsure() {
|
|
215
|
+
/** @type {string[]} */
|
|
216
|
+
const did = [];
|
|
217
|
+
try {
|
|
218
|
+
let createdConfig = false;
|
|
219
|
+
if (!configExists()) {
|
|
220
|
+
const cfg = normalizeConfig(defaultConfig(await detectSources()));
|
|
221
|
+
await saveConfig(cfg, CONFIG_PATH);
|
|
222
|
+
createdConfig = true;
|
|
223
|
+
did.push('created config');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const config = await loadConfig();
|
|
227
|
+
const mode =
|
|
228
|
+
config && config.injection && config.injection.mode
|
|
229
|
+
? config.injection.mode
|
|
230
|
+
: 'statusline';
|
|
231
|
+
|
|
232
|
+
if ((mode === 'statusline' || mode === 'both') && !statuslineIsOurs()) {
|
|
233
|
+
await installStatusline(config);
|
|
234
|
+
did.push('wired statusline');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!isDaemonRunning().running) {
|
|
238
|
+
startDaemonDetached();
|
|
239
|
+
did.push('started daemon');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (did.length === 0) {
|
|
243
|
+
console.log('ContextSpin: already set up.');
|
|
244
|
+
} else {
|
|
245
|
+
console.log(
|
|
246
|
+
`ContextSpin: ${did.join(', ')}.` +
|
|
247
|
+
(createdConfig ? ` Edit ${CONFIG_PATH} to add your own sources.` : ''),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const message = err && err.message ? err.message : String(err);
|
|
252
|
+
// Never break the session-start hook: report and exit 0.
|
|
253
|
+
console.log(`ContextSpin: setup skipped (${message}).`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
214
257
|
/**
|
|
215
258
|
* Start the background daemon. Requires a valid config.
|
|
216
259
|
* @returns {Promise<void>}
|
|
@@ -431,10 +474,17 @@ function buildProgram() {
|
|
|
431
474
|
|
|
432
475
|
program
|
|
433
476
|
.command('setup')
|
|
434
|
-
.description('Create a ContextSpin config (interactive, or --yes for
|
|
435
|
-
.option('--yes', 'skip prompts and write
|
|
477
|
+
.description('Create a ContextSpin config (interactive, or --yes for a detected config)')
|
|
478
|
+
.option('--yes', 'skip prompts and write a detected config')
|
|
436
479
|
.action(action(async (opts) => runSetup(opts)));
|
|
437
480
|
|
|
481
|
+
program
|
|
482
|
+
.command('ensure')
|
|
483
|
+
.description(
|
|
484
|
+
'One-shot, idempotent setup (create config + wire statusline + start daemon)',
|
|
485
|
+
)
|
|
486
|
+
.action(async () => runEnsure());
|
|
487
|
+
|
|
438
488
|
program
|
|
439
489
|
.command('start')
|
|
440
490
|
.description('Start the background daemon')
|
package/src/config.js
CHANGED
|
@@ -37,6 +37,13 @@ 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 command (captured when we wrap an
|
|
42
|
+
* existing statusline so we can run it and prepend its output). Holds
|
|
43
|
+
* { command, type }. Removed on uninstall.
|
|
44
|
+
*/
|
|
45
|
+
export const PREV_STATUSLINE_PATH = path.join(STATE_DIR, "prev-statusline.json");
|
|
46
|
+
|
|
40
47
|
/** Path to Claude Code's settings file (patched by the statusline injector). */
|
|
41
48
|
export const CLAUDE_SETTINGS_PATH = path.join(HOME, ".claude", "settings.json");
|
|
42
49
|
|
|
@@ -124,6 +131,35 @@ export function normalizeConfig(raw) {
|
|
|
124
131
|
return { ...input, injection, snippets, sources };
|
|
125
132
|
}
|
|
126
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Build a complete default config object from a set of sources. Mirrors the
|
|
136
|
+
* shipped example config's injection/snippets shape (statusline mode, 30s
|
|
137
|
+
* refresh, 5 visible, dedup on, a sensible priority order). The result is a
|
|
138
|
+
* plain config (NOT normalized) — pass it through normalizeConfig before use.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<object>} sources - Source objects (e.g. from detectSources).
|
|
141
|
+
* @returns {object} A default config: { sources, injection, snippets }.
|
|
142
|
+
*/
|
|
143
|
+
export function defaultConfig(sources) {
|
|
144
|
+
return {
|
|
145
|
+
sources: Array.isArray(sources) ? sources : [],
|
|
146
|
+
injection: { mode: "statusline", refresh: 30, maxVisible: 5 },
|
|
147
|
+
snippets: {
|
|
148
|
+
deduplication: true,
|
|
149
|
+
cooldownAfterShown: 3,
|
|
150
|
+
priorityOrder: [
|
|
151
|
+
"incident",
|
|
152
|
+
"ci",
|
|
153
|
+
"slack",
|
|
154
|
+
"calendar",
|
|
155
|
+
"github",
|
|
156
|
+
"gitlab",
|
|
157
|
+
"jira",
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
127
163
|
/**
|
|
128
164
|
* Validate a config (raw or normalized). Throws an Error with a clear message on
|
|
129
165
|
* any problem; returns the same config object on success.
|
|
@@ -135,8 +171,12 @@ export function validateConfig(config) {
|
|
|
135
171
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
136
172
|
throw new Error("Invalid config: expected a JSON object.");
|
|
137
173
|
}
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
// sources must be an array, but MAY be empty: a source-less config is valid —
|
|
175
|
+
// the daemon polls nothing and the injectors degrade to no snippets, which is
|
|
176
|
+
// the correct "installed but not configured yet" state (and lets `ensure`
|
|
177
|
+
// wire the statusline + start the daemon without a hard failure).
|
|
178
|
+
if (!Array.isArray(config.sources)) {
|
|
179
|
+
throw new Error('Invalid config: "sources" must be an array.');
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
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,144 @@
|
|
|
1
|
+
// src/detect.js — best-effort, zero-network detection of safe starter sources.
|
|
2
|
+
//
|
|
3
|
+
// Detection heuristics (all local, no secrets, no network):
|
|
4
|
+
// - We probe PATH for the `gh` (GitHub CLI), `glab` (GitLab CLI), and
|
|
5
|
+
// `kubectl` binaries using a short, swallowed child-process check
|
|
6
|
+
// (`<tool> --version`). Anything that errors, times out, or exits non-zero
|
|
7
|
+
// is treated as "not present".
|
|
8
|
+
// - If `gh` is present we seed two GitHub sources: PRs that requested your
|
|
9
|
+
// review, and failing CI runs.
|
|
10
|
+
// - Else if `glab` is present we seed the GitLab equivalents.
|
|
11
|
+
// - If NEITHER `gh` nor `glab` is present we still return the `gh` pair as a
|
|
12
|
+
// sensible placeholder. cli sources fail gracefully per-source in the
|
|
13
|
+
// daemon runner, so a missing binary just yields no snippets rather than
|
|
14
|
+
// breaking anything — and the config is then a working template the user
|
|
15
|
+
// can edit.
|
|
16
|
+
// - `kubectl` is probed for future use / informational purposes; we do not
|
|
17
|
+
// seed a kubectl source today because a safe, universally-meaningful
|
|
18
|
+
// read-only query is cluster-specific.
|
|
19
|
+
//
|
|
20
|
+
// All format/filter strings use the double-curly-brace token syntax understood
|
|
21
|
+
// by src/formatter.js. Returned source objects have NO `id` — normalizeConfig
|
|
22
|
+
// assigns ids by index.
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort check whether a binary is on PATH by running `<tool> --version`.
|
|
28
|
+
* Swallows every failure (missing binary, non-zero exit, timeout, spawn error)
|
|
29
|
+
* and resolves to a boolean. Never throws.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} tool - The binary name to probe (e.g. "gh").
|
|
32
|
+
* @param {number} [timeoutMs=2000] - Kill the probe after this long.
|
|
33
|
+
* @returns {Promise<boolean>}
|
|
34
|
+
*/
|
|
35
|
+
function hasBinary(tool, timeoutMs = 2000) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const done = (value) => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
resolve(value);
|
|
42
|
+
};
|
|
43
|
+
let child;
|
|
44
|
+
try {
|
|
45
|
+
child = spawn(tool, ["--version"], { stdio: "ignore" });
|
|
46
|
+
} catch {
|
|
47
|
+
done(false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
try {
|
|
52
|
+
child.kill("SIGKILL");
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
done(false);
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
if (timer.unref) timer.unref();
|
|
59
|
+
child.on("error", () => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
done(false);
|
|
62
|
+
});
|
|
63
|
+
child.on("close", (code) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
done(code === 0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The GitHub starter pair (PRs needing review + failing CI). */
|
|
71
|
+
function ghSources() {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
type: "cli",
|
|
75
|
+
command:
|
|
76
|
+
"gh pr list --review-requested @me --json title,number --limit 3",
|
|
77
|
+
format: "PR #{{ number }} needs review: {{ title }}",
|
|
78
|
+
label: "GitHub",
|
|
79
|
+
cooldown: 120,
|
|
80
|
+
maxSnippets: 3,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: "cli",
|
|
84
|
+
command: "gh run list --json status,name,headBranch --limit 5",
|
|
85
|
+
filter: "{{ status }} == failure",
|
|
86
|
+
format: "CI failing: {{ name }} on {{ headBranch }}",
|
|
87
|
+
label: "CI",
|
|
88
|
+
cooldown: 60,
|
|
89
|
+
maxSnippets: 2,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The GitLab starter pair (MRs needing review + failing CI). */
|
|
95
|
+
function glabSources() {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
type: "cli",
|
|
99
|
+
command: "glab mr list --reviewer=@me --output json --per-page 3",
|
|
100
|
+
format: "MR !{{ iid }} needs review: {{ title }}",
|
|
101
|
+
label: "GitLab",
|
|
102
|
+
cooldown: 120,
|
|
103
|
+
maxSnippets: 3,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "cli",
|
|
107
|
+
command: "glab ci list --status failed --output json --per-page 5",
|
|
108
|
+
format: "CI failed: {{ ref }} (#{{ id }})",
|
|
109
|
+
label: "CI",
|
|
110
|
+
cooldown: 60,
|
|
111
|
+
maxSnippets: 2,
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Detect a set of safe, read-only starter sources from the local environment.
|
|
118
|
+
*
|
|
119
|
+
* Best-effort and side-effect-free beyond local `<tool> --version` probes (no
|
|
120
|
+
* network). See the file header for the detection heuristics. Always returns a
|
|
121
|
+
* non-empty array of source objects WITHOUT ids (normalizeConfig assigns ids).
|
|
122
|
+
*
|
|
123
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
124
|
+
* @returns {Promise<Array<object>>}
|
|
125
|
+
*/
|
|
126
|
+
export async function detectSources(opts = {}) {
|
|
127
|
+
const timeoutMs = opts.timeoutMs;
|
|
128
|
+
|
|
129
|
+
// Probe the three tools in parallel; each probe swallows its own failures.
|
|
130
|
+
const [gh, glab /*, kubectl */] = await Promise.all([
|
|
131
|
+
hasBinary("gh", timeoutMs),
|
|
132
|
+
hasBinary("glab", timeoutMs),
|
|
133
|
+
hasBinary("kubectl", timeoutMs),
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
if (gh) return ghSources();
|
|
137
|
+
if (glab) return glabSources();
|
|
138
|
+
|
|
139
|
+
// Neither present: return the gh pair as a sensible, gracefully-failing
|
|
140
|
+
// placeholder the user can edit.
|
|
141
|
+
return ghSources();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default detectSources;
|
package/src/inject/statusline.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
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 ~/.claude/settings.json under the camelCase `statusLine` key.
|
|
4
|
+
//
|
|
5
|
+
// NON-DESTRUCTIVE: if the user already has a statusLine command, we record it to
|
|
6
|
+
// PREV_STATUSLINE_PATH and the generated render script RUNS that prior command
|
|
7
|
+
// (piping Claude Code's stdin to it) and prints its output FIRST, then prints the
|
|
8
|
+
// ContextSpin snippet on its own line beneath. The user's statusline is composed
|
|
9
|
+
// with ours, never discarded.
|
|
4
10
|
|
|
5
11
|
import fs from "node:fs";
|
|
6
12
|
import fsp from "node:fs/promises";
|
|
@@ -9,6 +15,7 @@ import {
|
|
|
9
15
|
STATE_DIR,
|
|
10
16
|
STATUSLINE_SH,
|
|
11
17
|
STATUSLINE_JS,
|
|
18
|
+
PREV_STATUSLINE_PATH,
|
|
12
19
|
CACHE_PATH,
|
|
13
20
|
CONFIG_PATH,
|
|
14
21
|
CLAUDE_SETTINGS_PATH,
|
|
@@ -19,71 +26,161 @@ import {
|
|
|
19
26
|
* for each status-bar refresh.
|
|
20
27
|
*
|
|
21
28
|
* Runtime behavior of the generated script:
|
|
22
|
-
* - Reads and
|
|
23
|
-
*
|
|
24
|
-
*
|
|
29
|
+
* - Reads and BUFFERS all of stdin (Claude Code pipes a JSON payload). We must
|
|
30
|
+
* consume it so the writer never gets EPIPE; we also feed it to a wrapped
|
|
31
|
+
* prior statusline command (below).
|
|
32
|
+
* - If PREV_STATUSLINE_PATH exists and names a command, spawns that command via
|
|
33
|
+
* the shell, writes the buffered stdin to ITS stdin, captures its stdout with
|
|
34
|
+
* a short timeout (killed on timeout), and prints that output VERBATIM first
|
|
35
|
+
* (it may be multiple lines). Any failure here is swallowed.
|
|
36
|
+
* - Reads the cache (tolerating a missing file).
|
|
25
37
|
* - Reads `cooldownAfterShown` from the config (fallback 3).
|
|
26
38
|
* - Selects snippets where shownCount < cooldownAfterShown, picks the one with
|
|
27
39
|
* the LOWEST shownCount then the most recent fetchedAt, bumps its shownCount,
|
|
28
40
|
* and writes the cache back atomically.
|
|
29
|
-
* - Prints that snippet's text on
|
|
30
|
-
*
|
|
31
|
-
*
|
|
41
|
+
* - Prints that snippet's text on its OWN line beneath the prior output; prints
|
|
42
|
+
* nothing for the ContextSpin line if none eligible.
|
|
43
|
+
* - Wraps EVERYTHING so any error still exits 0 with whatever output succeeded
|
|
44
|
+
* (the prior statusline must never be lost and the bar must never break).
|
|
32
45
|
*
|
|
33
|
-
* The cache and
|
|
34
|
-
* generated file is fully self-contained
|
|
46
|
+
* The cache, config, and prev-statusline paths are baked into the script as
|
|
47
|
+
* string literals so the generated file is fully self-contained with no imports
|
|
48
|
+
* beyond node builtins.
|
|
35
49
|
*
|
|
36
50
|
* @param {string} cachePath - Absolute path to the snippet cache JSON file.
|
|
37
51
|
* @param {string} configPath - Absolute path to the ContextSpin config JSON file.
|
|
52
|
+
* @param {string} prevPath - Absolute path to the prev-statusline JSON file.
|
|
38
53
|
* @returns {string} The ESM source of the render script.
|
|
39
54
|
*/
|
|
40
|
-
function buildRenderScript(cachePath, configPath) {
|
|
55
|
+
function buildRenderScript(cachePath, configPath, prevPath) {
|
|
41
56
|
const CACHE = JSON.stringify(cachePath);
|
|
42
57
|
const CONFIG = JSON.stringify(configPath);
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const PREV = JSON.stringify(prevPath);
|
|
59
|
+
return `// contextspin statusline-render.js (generated) — composes any prior
|
|
60
|
+
// statusline with one ContextSpin snippet line. MUST always exit 0 and never
|
|
61
|
+
// lose the prior statusline's output, so the user's status bar never breaks.
|
|
45
62
|
import fs from "node:fs";
|
|
63
|
+
import { spawn } from "node:child_process";
|
|
46
64
|
|
|
47
65
|
const CACHE_PATH = ${CACHE};
|
|
48
66
|
const CONFIG_PATH = ${CONFIG};
|
|
67
|
+
const PREV_STATUSLINE_PATH = ${PREV};
|
|
49
68
|
|
|
50
|
-
/**
|
|
51
|
-
function
|
|
69
|
+
/** Buffer ALL of stdin into a Buffer. Resolves on end/close/error/timeout. */
|
|
70
|
+
function readStdin() {
|
|
52
71
|
return new Promise((resolve) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let done = false;
|
|
74
|
+
const finish = () => {
|
|
75
|
+
if (done) return;
|
|
76
|
+
done = true;
|
|
77
|
+
resolve(Buffer.concat(chunks));
|
|
78
|
+
};
|
|
53
79
|
try {
|
|
54
80
|
const stdin = process.stdin;
|
|
55
|
-
stdin.on("error", () =>
|
|
56
|
-
stdin.on("data", () =>
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
stdin.on("error", () => finish());
|
|
82
|
+
stdin.on("data", (chunk) =>
|
|
83
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
|
|
84
|
+
);
|
|
85
|
+
stdin.on("end", () => finish());
|
|
86
|
+
stdin.on("close", () => finish());
|
|
59
87
|
stdin.resume();
|
|
60
88
|
// Safety timer: don't hang forever if no EOF arrives.
|
|
61
|
-
setTimeout(
|
|
89
|
+
setTimeout(finish, 250).unref?.();
|
|
62
90
|
} catch {
|
|
63
|
-
|
|
91
|
+
finish();
|
|
64
92
|
}
|
|
65
93
|
});
|
|
66
94
|
}
|
|
67
95
|
|
|
68
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Run the recorded prior statusline command, feeding it the buffered stdin, and
|
|
98
|
+
* resolve with its captured stdout (string). Swallows every failure -> "".
|
|
99
|
+
*/
|
|
100
|
+
function runPrevStatusline(stdinBuf) {
|
|
101
|
+
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
|
+
if (!command) {
|
|
112
|
+
resolve("");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let child;
|
|
116
|
+
try {
|
|
117
|
+
child = spawn(command, { shell: true });
|
|
118
|
+
} catch {
|
|
119
|
+
resolve("");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let out = "";
|
|
123
|
+
let settled = false;
|
|
124
|
+
const finish = () => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
settled = true;
|
|
127
|
+
resolve(out);
|
|
128
|
+
};
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
try {
|
|
131
|
+
child.kill("SIGKILL");
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
finish();
|
|
136
|
+
}, 2000);
|
|
137
|
+
if (timer.unref) timer.unref();
|
|
138
|
+
if (child.stdout) {
|
|
139
|
+
child.stdout.setEncoding("utf8");
|
|
140
|
+
child.stdout.on("data", (chunk) => {
|
|
141
|
+
out += chunk;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (child.stderr) child.stderr.on("data", () => {});
|
|
145
|
+
child.on("error", () => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
finish();
|
|
148
|
+
});
|
|
149
|
+
child.on("close", () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
finish();
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
if (child.stdin) {
|
|
155
|
+
child.stdin.on("error", () => {});
|
|
156
|
+
if (stdinBuf && stdinBuf.length) child.stdin.write(stdinBuf);
|
|
157
|
+
child.stdin.end();
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// ignore: prior command may not read stdin
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Atomically replace a JSON file (write tmp then rename). The temp name is
|
|
166
|
+
// per-process so this render script and the daemon never share one .tmp and
|
|
167
|
+
// tear each other's cache writes. */
|
|
69
168
|
function writeJsonAtomic(filePath, data) {
|
|
70
|
-
const tmp = filePath + ".tmp";
|
|
169
|
+
const tmp = filePath + "." + process.pid + ".tmp";
|
|
71
170
|
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
72
171
|
fs.renameSync(tmp, filePath);
|
|
73
172
|
}
|
|
74
173
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Missing cache -> nothing to show.
|
|
174
|
+
/** Compute the ContextSpin snippet line (may be ""); bumps shownCount. */
|
|
175
|
+
function contextSpinLine() {
|
|
79
176
|
let cache;
|
|
80
177
|
try {
|
|
81
178
|
cache = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
|
|
82
179
|
} catch {
|
|
83
|
-
return;
|
|
180
|
+
return "";
|
|
84
181
|
}
|
|
85
182
|
const snippets = Array.isArray(cache && cache.snippets) ? cache.snippets : [];
|
|
86
|
-
if (snippets.length === 0) return;
|
|
183
|
+
if (snippets.length === 0) return "";
|
|
87
184
|
|
|
88
185
|
// cooldownAfterShown from config (fallback 3).
|
|
89
186
|
let cooldownAfterShown = 3;
|
|
@@ -95,13 +192,11 @@ async function main() {
|
|
|
95
192
|
// keep fallback
|
|
96
193
|
}
|
|
97
194
|
|
|
98
|
-
// Eligible: shownCount < cooldownAfterShown.
|
|
99
195
|
const eligible = snippets.filter(
|
|
100
196
|
(s) => s && typeof s.text === "string" && (s.shownCount || 0) < cooldownAfterShown
|
|
101
197
|
);
|
|
102
|
-
if (eligible.length === 0) return;
|
|
198
|
+
if (eligible.length === 0) return "";
|
|
103
199
|
|
|
104
|
-
// Pick lowest shownCount, then most recent fetchedAt.
|
|
105
200
|
eligible.sort((a, b) => {
|
|
106
201
|
const ca = a.shownCount || 0;
|
|
107
202
|
const cb = b.shownCount || 0;
|
|
@@ -112,7 +207,6 @@ async function main() {
|
|
|
112
207
|
});
|
|
113
208
|
const chosen = eligible[0];
|
|
114
209
|
|
|
115
|
-
// Bump shownCount on the chosen snippet within the original array and persist.
|
|
116
210
|
chosen.shownCount = (chosen.shownCount || 0) + 1;
|
|
117
211
|
try {
|
|
118
212
|
writeJsonAtomic(CACHE_PATH, cache);
|
|
@@ -120,13 +214,50 @@ async function main() {
|
|
|
120
214
|
// If we cannot persist, still show the snippet this time.
|
|
121
215
|
}
|
|
122
216
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
217
|
+
return String(chosen.text).replace(/\\r?\\n/g, " ");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Write a string to stdout, awaiting the flush callback. */
|
|
221
|
+
function writeOut(text) {
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
try {
|
|
224
|
+
process.stdout.write(text, resolve);
|
|
225
|
+
} catch {
|
|
226
|
+
resolve();
|
|
227
|
+
}
|
|
127
228
|
});
|
|
128
229
|
}
|
|
129
230
|
|
|
231
|
+
async function main() {
|
|
232
|
+
const stdinBuf = await readStdin();
|
|
233
|
+
|
|
234
|
+
// (a) Prior statusline output FIRST (verbatim, possibly multi-line).
|
|
235
|
+
let prevOut = "";
|
|
236
|
+
try {
|
|
237
|
+
prevOut = await runPrevStatusline(stdinBuf);
|
|
238
|
+
} catch {
|
|
239
|
+
prevOut = "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// (b) ContextSpin snippet line.
|
|
243
|
+
let line = "";
|
|
244
|
+
try {
|
|
245
|
+
line = contextSpinLine();
|
|
246
|
+
} catch {
|
|
247
|
+
line = "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// (c) Compose: prior output, then our line on its own line beneath. We only
|
|
251
|
+
// insert a separating newline when there is prior output that does not
|
|
252
|
+
// already end in one, so a lone ContextSpin line stays a single clean line.
|
|
253
|
+
let composed = prevOut;
|
|
254
|
+
if (line) {
|
|
255
|
+
if (composed && !composed.endsWith("\\n")) composed += "\\n";
|
|
256
|
+
composed += line;
|
|
257
|
+
}
|
|
258
|
+
if (composed) await writeOut(composed);
|
|
259
|
+
}
|
|
260
|
+
|
|
130
261
|
main()
|
|
131
262
|
.then(() => process.exit(0))
|
|
132
263
|
.catch(() => process.exit(0));
|
|
@@ -166,17 +297,22 @@ async function writeJsonAtomic(filePath, data) {
|
|
|
166
297
|
* @property {string} statuslineJs - Path to the generated Node render script.
|
|
167
298
|
* @property {string} settingsPath - Path to the patched Claude settings file.
|
|
168
299
|
* @property {boolean} backedUp - Whether an existing statusLine was backed up.
|
|
300
|
+
* @property {boolean} composed - Whether we wrapped an existing statusline
|
|
301
|
+
* (its output is composed above the ContextSpin line).
|
|
169
302
|
* @property {string|null} warning - Human-readable warning, or null.
|
|
170
303
|
*/
|
|
171
304
|
|
|
172
305
|
/**
|
|
173
|
-
* Install the ContextSpin statusline integration:
|
|
306
|
+
* Install the ContextSpin statusline integration (NON-DESTRUCTIVE):
|
|
174
307
|
* - Writes the self-contained render script to STATUSLINE_JS.
|
|
175
308
|
* - Writes an executable bash wrapper to STATUSLINE_SH that execs the render
|
|
176
309
|
* script with stderr silenced.
|
|
310
|
+
* - If an existing statusLine command (other than ours) is present, RECORDS it
|
|
311
|
+
* to PREV_STATUSLINE_PATH (once — idempotent; never captures our own command)
|
|
312
|
+
* so the render script can run it and prepend its output. Also backs up
|
|
313
|
+
* settings.json to the .contextspin.bak once, as before.
|
|
177
314
|
* - Patches ~/.claude/settings.json so `statusLine` points at our wrapper, with
|
|
178
|
-
* `refreshInterval` in SECONDS (from config.injection.refresh).
|
|
179
|
-
* statusLine command (other than ours) is present, it is backed up once.
|
|
315
|
+
* `refreshInterval` in SECONDS (from config.injection.refresh).
|
|
180
316
|
*
|
|
181
317
|
* @param {object} config - Normalized ContextSpin config (uses injection.refresh).
|
|
182
318
|
* @returns {Promise<InstallStatuslineResult>}
|
|
@@ -184,21 +320,15 @@ async function writeJsonAtomic(filePath, data) {
|
|
|
184
320
|
export async function installStatusline(config) {
|
|
185
321
|
await fsp.mkdir(STATE_DIR, { recursive: true });
|
|
186
322
|
|
|
187
|
-
// (1)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// (2) Bash wrapper. Silence stderr so node warnings never reach the status bar.
|
|
192
|
-
const shSource = `#!/usr/bin/env bash\nexec node ${JSON.stringify(STATUSLINE_JS)} 2>/dev/null\n`;
|
|
193
|
-
await fsp.writeFile(STATUSLINE_SH, shSource);
|
|
194
|
-
await fsp.chmod(STATUSLINE_SH, 0o755);
|
|
195
|
-
|
|
196
|
-
// (3) Patch Claude settings.
|
|
323
|
+
// (1) Patch Claude settings — first detect/record any existing statusline so
|
|
324
|
+
// the generated render script can compose it. (We read settings before
|
|
325
|
+
// writing the render script so a re-run never captures our own command.)
|
|
197
326
|
await fsp.mkdir(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
|
|
198
327
|
const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, {});
|
|
199
328
|
const settingsObj = settings && typeof settings === "object" ? settings : {};
|
|
200
329
|
|
|
201
330
|
let backedUp = false;
|
|
331
|
+
let composed = false;
|
|
202
332
|
let warning = null;
|
|
203
333
|
|
|
204
334
|
const existing = settingsObj.statusLine;
|
|
@@ -208,16 +338,47 @@ export async function installStatusline(config) {
|
|
|
208
338
|
existing.command &&
|
|
209
339
|
existing.command !== STATUSLINE_SH
|
|
210
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;
|
|
346
|
+
try {
|
|
347
|
+
recordedPrev = JSON.parse(fs.readFileSync(PREV_STATUSLINE_PATH, "utf8"));
|
|
348
|
+
} catch {
|
|
349
|
+
recordedPrev = null;
|
|
350
|
+
}
|
|
351
|
+
if (!recordedPrev || recordedPrev.command !== String(existing.command)) {
|
|
352
|
+
await writeJsonAtomic(PREV_STATUSLINE_PATH, {
|
|
353
|
+
command: String(existing.command),
|
|
354
|
+
type: existing.type || "command",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
composed = true;
|
|
358
|
+
|
|
211
359
|
const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
|
|
212
360
|
if (!fs.existsSync(backupPath)) {
|
|
213
361
|
await fsp.copyFile(CLAUDE_SETTINGS_PATH, backupPath);
|
|
214
362
|
backedUp = true;
|
|
215
363
|
}
|
|
216
364
|
warning =
|
|
217
|
-
`Existing statusLine command
|
|
365
|
+
`Existing statusLine command (\`${existing.command}\`) is preserved: ` +
|
|
366
|
+
`ContextSpin runs it and shows its output above the ContextSpin line. ` +
|
|
218
367
|
`A backup of your settings is at ${backupPath}. Run \`contextspin uninject\` to restore it.`;
|
|
368
|
+
} else if (existing && typeof existing === "object" && existing.command === STATUSLINE_SH) {
|
|
369
|
+
// Already ours: a prior command may have been recorded on a previous run.
|
|
370
|
+
composed = fs.existsSync(PREV_STATUSLINE_PATH);
|
|
219
371
|
}
|
|
220
372
|
|
|
373
|
+
// (2) Render script (now knows the prev-statusline path).
|
|
374
|
+
const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH);
|
|
375
|
+
await fsp.writeFile(STATUSLINE_JS, renderSource);
|
|
376
|
+
|
|
377
|
+
// (3) Bash wrapper. Silence stderr so node warnings never reach the status bar.
|
|
378
|
+
const shSource = `#!/usr/bin/env bash\nexec node ${JSON.stringify(STATUSLINE_JS)} 2>/dev/null\n`;
|
|
379
|
+
await fsp.writeFile(STATUSLINE_SH, shSource);
|
|
380
|
+
await fsp.chmod(STATUSLINE_SH, 0o755);
|
|
381
|
+
|
|
221
382
|
const refresh =
|
|
222
383
|
config && config.injection && typeof config.injection.refresh === "number"
|
|
223
384
|
? config.injection.refresh
|
|
@@ -237,6 +398,7 @@ export async function installStatusline(config) {
|
|
|
237
398
|
statuslineJs: STATUSLINE_JS,
|
|
238
399
|
settingsPath: CLAUDE_SETTINGS_PATH,
|
|
239
400
|
backedUp,
|
|
401
|
+
composed,
|
|
240
402
|
warning,
|
|
241
403
|
};
|
|
242
404
|
}
|
|
@@ -249,10 +411,20 @@ export async function installStatusline(config) {
|
|
|
249
411
|
* @property {string|null} note - Human-readable note, or null.
|
|
250
412
|
*/
|
|
251
413
|
|
|
414
|
+
/** Best-effort removal of the recorded prev-statusline file. */
|
|
415
|
+
async function removePrevStatusline() {
|
|
416
|
+
try {
|
|
417
|
+
await fsp.unlink(PREV_STATUSLINE_PATH);
|
|
418
|
+
} catch {
|
|
419
|
+
// best effort (may not exist)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
252
423
|
/**
|
|
253
424
|
* Uninstall the ContextSpin statusline integration. If the current
|
|
254
425
|
* `statusLine.command` is ours, restore the `.contextspin.bak` backup when
|
|
255
|
-
* present, otherwise just drop the
|
|
426
|
+
* present (which brings back the prior command), otherwise just drop the
|
|
427
|
+
* `statusLine` key. Always removes the recorded prev-statusline file.
|
|
256
428
|
*
|
|
257
429
|
* @returns {Promise<UninstallStatuslineResult>}
|
|
258
430
|
*/
|
|
@@ -290,6 +462,7 @@ export async function uninstallStatusline() {
|
|
|
290
462
|
} catch {
|
|
291
463
|
// best effort
|
|
292
464
|
}
|
|
465
|
+
await removePrevStatusline();
|
|
293
466
|
return {
|
|
294
467
|
removed: true,
|
|
295
468
|
restored: true,
|
|
@@ -301,6 +474,7 @@ export async function uninstallStatusline() {
|
|
|
301
474
|
|
|
302
475
|
delete settings.statusLine;
|
|
303
476
|
await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
|
|
477
|
+
await removePrevStatusline();
|
|
304
478
|
return {
|
|
305
479
|
removed: true,
|
|
306
480
|
restored: false,
|