contextspin 0.1.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.
@@ -0,0 +1,310 @@
1
+ // src/inject/statusline.js — installs/uninstalls the Claude Code statusLine integration.
2
+ // Generates a self-contained render script (always exits 0, reads+discards stdin)
3
+ // and wires it into ~/.claude/settings.json under the camelCase `statusLine` key.
4
+
5
+ import fs from "node:fs";
6
+ import fsp from "node:fs/promises";
7
+ import path from "node:path";
8
+ import {
9
+ STATE_DIR,
10
+ STATUSLINE_SH,
11
+ STATUSLINE_JS,
12
+ CACHE_PATH,
13
+ CONFIG_PATH,
14
+ CLAUDE_SETTINGS_PATH,
15
+ } from "../config.js";
16
+
17
+ /**
18
+ * Build the source text of the Node ESM render script that Claude Code invokes
19
+ * for each status-bar refresh.
20
+ *
21
+ * Runtime behavior of the generated script:
22
+ * - Reads and DISCARDS all of stdin (Claude Code pipes a JSON payload; we never
23
+ * use it, but we must drain it so the writer never gets EPIPE).
24
+ * - Reads the cache (tolerating a missing file -> exit 0 silently).
25
+ * - Reads `cooldownAfterShown` from the config (fallback 3).
26
+ * - Selects snippets where shownCount < cooldownAfterShown, picks the one with
27
+ * the LOWEST shownCount then the most recent fetchedAt, bumps its shownCount,
28
+ * and writes the cache back atomically.
29
+ * - Prints that snippet's text on a single line; prints nothing if none eligible.
30
+ * - Wraps EVERYTHING so any error still exits 0 with no output (never breaks the
31
+ * user's status bar).
32
+ *
33
+ * The cache and config paths are baked into the script as string literals so the
34
+ * generated file is fully self-contained and has no import dependencies.
35
+ *
36
+ * @param {string} cachePath - Absolute path to the snippet cache JSON file.
37
+ * @param {string} configPath - Absolute path to the ContextSpin config JSON file.
38
+ * @returns {string} The ESM source of the render script.
39
+ */
40
+ function buildRenderScript(cachePath, configPath) {
41
+ const CACHE = JSON.stringify(cachePath);
42
+ const CONFIG = JSON.stringify(configPath);
43
+ return `// contextspin statusline-render.js (generated) — prints one context snippet.
44
+ // MUST always exit 0 and print nothing on error so the status bar never breaks.
45
+ import fs from "node:fs";
46
+
47
+ const CACHE_PATH = ${CACHE};
48
+ const CONFIG_PATH = ${CONFIG};
49
+
50
+ /** Drain and discard stdin so Claude Code's JSON pipe never gets EPIPE. */
51
+ function drainStdin() {
52
+ return new Promise((resolve) => {
53
+ try {
54
+ const stdin = process.stdin;
55
+ stdin.on("error", () => {});
56
+ stdin.on("data", () => {});
57
+ stdin.on("end", () => resolve());
58
+ stdin.on("close", () => resolve());
59
+ stdin.resume();
60
+ // Safety timer: don't hang forever if no EOF arrives.
61
+ setTimeout(resolve, 250).unref?.();
62
+ } catch {
63
+ resolve();
64
+ }
65
+ });
66
+ }
67
+
68
+ /** Atomically replace a JSON file (write tmp then rename). */
69
+ function writeJsonAtomic(filePath, data) {
70
+ const tmp = filePath + ".tmp";
71
+ fs.writeFileSync(tmp, JSON.stringify(data));
72
+ fs.renameSync(tmp, filePath);
73
+ }
74
+
75
+ async function main() {
76
+ await drainStdin();
77
+
78
+ // Missing cache -> nothing to show.
79
+ let cache;
80
+ try {
81
+ cache = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
82
+ } catch {
83
+ return; // no output
84
+ }
85
+ const snippets = Array.isArray(cache && cache.snippets) ? cache.snippets : [];
86
+ if (snippets.length === 0) return;
87
+
88
+ // cooldownAfterShown from config (fallback 3).
89
+ let cooldownAfterShown = 3;
90
+ try {
91
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
92
+ const v = cfg && cfg.snippets && cfg.snippets.cooldownAfterShown;
93
+ if (typeof v === "number" && Number.isFinite(v)) cooldownAfterShown = v;
94
+ } catch {
95
+ // keep fallback
96
+ }
97
+
98
+ // Eligible: shownCount < cooldownAfterShown.
99
+ const eligible = snippets.filter(
100
+ (s) => s && typeof s.text === "string" && (s.shownCount || 0) < cooldownAfterShown
101
+ );
102
+ if (eligible.length === 0) return;
103
+
104
+ // Pick lowest shownCount, then most recent fetchedAt.
105
+ eligible.sort((a, b) => {
106
+ const ca = a.shownCount || 0;
107
+ const cb = b.shownCount || 0;
108
+ if (ca !== cb) return ca - cb;
109
+ const ta = Date.parse(a.fetchedAt || "") || 0;
110
+ const tb = Date.parse(b.fetchedAt || "") || 0;
111
+ return tb - ta;
112
+ });
113
+ const chosen = eligible[0];
114
+
115
+ // Bump shownCount on the chosen snippet within the original array and persist.
116
+ chosen.shownCount = (chosen.shownCount || 0) + 1;
117
+ try {
118
+ writeJsonAtomic(CACHE_PATH, cache);
119
+ } catch {
120
+ // If we cannot persist, still show the snippet this time.
121
+ }
122
+
123
+ // Single line of output. Await the write callback so the bytes are flushed to
124
+ // the pipe before process.exit (which does not wait for async stdout writes).
125
+ await new Promise((resolve) => {
126
+ process.stdout.write(String(chosen.text).replace(/\\r?\\n/g, " "), resolve);
127
+ });
128
+ }
129
+
130
+ main()
131
+ .then(() => process.exit(0))
132
+ .catch(() => process.exit(0));
133
+ `;
134
+ }
135
+
136
+ /**
137
+ * Read a JSON file, returning a fallback value on any read/parse error.
138
+ * @param {string} filePath
139
+ * @param {*} fallback
140
+ * @returns {Promise<*>}
141
+ */
142
+ async function readJsonSafe(filePath, fallback) {
143
+ try {
144
+ const raw = await fsp.readFile(filePath, "utf8");
145
+ return JSON.parse(raw);
146
+ } catch {
147
+ return fallback;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Atomically write a pretty-printed JSON file (write tmp then rename).
153
+ * @param {string} filePath
154
+ * @param {*} data
155
+ * @returns {Promise<void>}
156
+ */
157
+ async function writeJsonAtomic(filePath, data) {
158
+ const tmp = filePath + ".tmp";
159
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2));
160
+ await fsp.rename(tmp, filePath);
161
+ }
162
+
163
+ /**
164
+ * @typedef {Object} InstallStatuslineResult
165
+ * @property {string} statuslineSh - Path to the generated bash wrapper.
166
+ * @property {string} statuslineJs - Path to the generated Node render script.
167
+ * @property {string} settingsPath - Path to the patched Claude settings file.
168
+ * @property {boolean} backedUp - Whether an existing statusLine was backed up.
169
+ * @property {string|null} warning - Human-readable warning, or null.
170
+ */
171
+
172
+ /**
173
+ * Install the ContextSpin statusline integration:
174
+ * - Writes the self-contained render script to STATUSLINE_JS.
175
+ * - Writes an executable bash wrapper to STATUSLINE_SH that execs the render
176
+ * script with stderr silenced.
177
+ * - Patches ~/.claude/settings.json so `statusLine` points at our wrapper, with
178
+ * `refreshInterval` in SECONDS (from config.injection.refresh). If an existing
179
+ * statusLine command (other than ours) is present, it is backed up once.
180
+ *
181
+ * @param {object} config - Normalized ContextSpin config (uses injection.refresh).
182
+ * @returns {Promise<InstallStatuslineResult>}
183
+ */
184
+ export async function installStatusline(config) {
185
+ await fsp.mkdir(STATE_DIR, { recursive: true });
186
+
187
+ // (1) Render script.
188
+ const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH);
189
+ await fsp.writeFile(STATUSLINE_JS, renderSource);
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.
197
+ await fsp.mkdir(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
198
+ const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, {});
199
+ const settingsObj = settings && typeof settings === "object" ? settings : {};
200
+
201
+ let backedUp = false;
202
+ let warning = null;
203
+
204
+ const existing = settingsObj.statusLine;
205
+ if (
206
+ existing &&
207
+ typeof existing === "object" &&
208
+ existing.command &&
209
+ existing.command !== STATUSLINE_SH
210
+ ) {
211
+ const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
212
+ if (!fs.existsSync(backupPath)) {
213
+ await fsp.copyFile(CLAUDE_SETTINGS_PATH, backupPath);
214
+ backedUp = true;
215
+ }
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
+ }
220
+
221
+ const refresh =
222
+ config && config.injection && typeof config.injection.refresh === "number"
223
+ ? config.injection.refresh
224
+ : 30;
225
+
226
+ settingsObj.statusLine = {
227
+ type: "command",
228
+ command: STATUSLINE_SH,
229
+ padding: 0,
230
+ refreshInterval: refresh, // SECONDS
231
+ };
232
+
233
+ await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settingsObj);
234
+
235
+ return {
236
+ statuslineSh: STATUSLINE_SH,
237
+ statuslineJs: STATUSLINE_JS,
238
+ settingsPath: CLAUDE_SETTINGS_PATH,
239
+ backedUp,
240
+ warning,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * @typedef {Object} UninstallStatuslineResult
246
+ * @property {boolean} removed - Whether our statusLine entry was removed.
247
+ * @property {boolean} restored - Whether settings were restored from backup.
248
+ * @property {string} settingsPath - Path to the Claude settings file.
249
+ * @property {string|null} note - Human-readable note, or null.
250
+ */
251
+
252
+ /**
253
+ * Uninstall the ContextSpin statusline integration. If the current
254
+ * `statusLine.command` is ours, restore the `.contextspin.bak` backup when
255
+ * present, otherwise just drop the `statusLine` key.
256
+ *
257
+ * @returns {Promise<UninstallStatuslineResult>}
258
+ */
259
+ export async function uninstallStatusline() {
260
+ const settings = await readJsonSafe(CLAUDE_SETTINGS_PATH, null);
261
+ if (!settings || typeof settings !== "object") {
262
+ return {
263
+ removed: false,
264
+ restored: false,
265
+ settingsPath: CLAUDE_SETTINGS_PATH,
266
+ note: "No Claude settings file found; nothing to uninstall.",
267
+ };
268
+ }
269
+
270
+ const current = settings.statusLine;
271
+ const isOurs =
272
+ current && typeof current === "object" && current.command === STATUSLINE_SH;
273
+
274
+ if (!isOurs) {
275
+ return {
276
+ removed: false,
277
+ restored: false,
278
+ settingsPath: CLAUDE_SETTINGS_PATH,
279
+ note: "statusLine is not managed by ContextSpin; left unchanged.",
280
+ };
281
+ }
282
+
283
+ const backupPath = CLAUDE_SETTINGS_PATH + ".contextspin.bak";
284
+ if (fs.existsSync(backupPath)) {
285
+ const backup = await readJsonSafe(backupPath, null);
286
+ if (backup && typeof backup === "object") {
287
+ await writeJsonAtomic(CLAUDE_SETTINGS_PATH, backup);
288
+ try {
289
+ await fsp.unlink(backupPath);
290
+ } catch {
291
+ // best effort
292
+ }
293
+ return {
294
+ removed: true,
295
+ restored: true,
296
+ settingsPath: CLAUDE_SETTINGS_PATH,
297
+ note: "Restored previous Claude settings from backup.",
298
+ };
299
+ }
300
+ }
301
+
302
+ delete settings.statusLine;
303
+ await writeJsonAtomic(CLAUDE_SETTINGS_PATH, settings);
304
+ return {
305
+ removed: true,
306
+ restored: false,
307
+ settingsPath: CLAUDE_SETTINGS_PATH,
308
+ note: "Removed the ContextSpin statusLine entry.",
309
+ };
310
+ }
package/src/runner.js ADDED
@@ -0,0 +1,69 @@
1
+ // src/runner.js — dispatches a configured source to its fetcher, then filters/formats/slices into Snippets.
2
+
3
+ import fetchCli from "./sources/cli.js";
4
+ import fetchHttp from "./sources/http.js";
5
+ import fetchMcp from "./sources/mcp.js";
6
+ import { interpolate, applyFilter } from "./formatter.js";
7
+
8
+ /**
9
+ * A Snippet is the unit ContextSpin caches and injects.
10
+ * @typedef {object} Snippet
11
+ * @property {string} text Rendered, human-readable one-liner (from source.format).
12
+ * @property {string} source The source label (source.label).
13
+ * @property {number} sourceId The source's index/id within the config.
14
+ * @property {string} fetchedAt ISO-8601 timestamp of when it was produced.
15
+ * @property {number} shownCount How many times it has been shown (starts at 0).
16
+ */
17
+
18
+ /**
19
+ * Current time as an ISO-8601 string.
20
+ * @returns {string}
21
+ */
22
+ export function nowISO() {
23
+ return new Date().toISOString();
24
+ }
25
+
26
+ /**
27
+ * Fetch a single source, filter + format its records, and return capped Snippets.
28
+ *
29
+ * Dispatches by source.type to the matching fetcher (cli|http|mcp), passing opts
30
+ * through. Each returned record is kept only when applyFilter(source.filter, record)
31
+ * is true; its text is interpolate(source.format, record); empty text is skipped.
32
+ * The resulting array is sliced to source.maxSnippets. Errors propagate to the caller.
33
+ *
34
+ * @param {object} source - Normalized source definition (has type, format, label, id, maxSnippets).
35
+ * @param {object} [opts] - Passed through to the underlying fetcher (e.g. timeoutMs, cwd, env).
36
+ * @returns {Promise<Snippet[]>}
37
+ */
38
+ export async function runSource(source, opts = {}) {
39
+ let records;
40
+ switch (source.type) {
41
+ case "cli":
42
+ records = await fetchCli(source, opts);
43
+ break;
44
+ case "http":
45
+ records = await fetchHttp(source, opts);
46
+ break;
47
+ case "mcp":
48
+ records = await fetchMcp(source, opts);
49
+ break;
50
+ default:
51
+ throw new Error(`Unknown source type: ${source.type}`);
52
+ }
53
+
54
+ const snippets = [];
55
+ for (const record of records) {
56
+ if (!applyFilter(source.filter, record)) continue;
57
+ const text = interpolate(source.format, record);
58
+ if (text.trim() === "") continue;
59
+ snippets.push({
60
+ text,
61
+ source: source.label,
62
+ sourceId: source.id,
63
+ fetchedAt: nowISO(),
64
+ shownCount: 0,
65
+ });
66
+ }
67
+
68
+ return snippets.slice(0, source.maxSnippets);
69
+ }
@@ -0,0 +1,148 @@
1
+ // src/sources/cli.js — CLI source: run a shell command and turn its stdout into records.
2
+
3
+ import { spawn } from 'node:child_process';
4
+
5
+ /**
6
+ * Run a CLI command and parse its stdout into an array of record objects.
7
+ *
8
+ * The command is spawned with a shell so users can write normal shell strings
9
+ * (pipes, flags, quotes). stdout and stderr are buffered. A timeout kills the
10
+ * child (SIGTERM, then SIGKILL after a short grace period).
11
+ *
12
+ * Parsing rules for stdout (after trimming):
13
+ * - "" (empty) -> []
14
+ * - valid JSON array -> each element mapped (object kept as-is;
15
+ * primitive -> { value: el, text: String(el) })
16
+ * - valid JSON object -> [obj]
17
+ * - valid JSON primitive -> [{ value: parsed, text: String(parsed) }]
18
+ * - not JSON -> split into non-empty trimmed lines;
19
+ * each line -> { text: line, line, value: line }
20
+ *
21
+ * @param {{ command: string }} source - The CLI source definition.
22
+ * @param {{ timeoutMs?: number }} [opts] - Options (timeoutMs default 15000).
23
+ * @returns {Promise<Array<object>>} Parsed records.
24
+ */
25
+ export async function fetchCli(source, opts = {}) {
26
+ const timeoutMs = opts.timeoutMs ?? 15000;
27
+ const command = source.command;
28
+
29
+ const { stdout } = await runCommand(command, timeoutMs);
30
+ return parseCliStdout(stdout);
31
+ }
32
+
33
+ /**
34
+ * Spawn a shell command, buffer stdout/stderr, and enforce a timeout.
35
+ *
36
+ * @param {string} command - The shell command to run.
37
+ * @param {number} timeoutMs - Timeout in milliseconds.
38
+ * @returns {Promise<{ stdout: string, stderr: string }>}
39
+ */
40
+ function runCommand(command, timeoutMs) {
41
+ return new Promise((resolve, reject) => {
42
+ const child = spawn(command, { shell: true });
43
+
44
+ let stdout = '';
45
+ let stderr = '';
46
+ let settled = false;
47
+ let killTimer = null;
48
+ let graceTimer = null;
49
+
50
+ const clearTimers = () => {
51
+ if (killTimer) clearTimeout(killTimer);
52
+ if (graceTimer) clearTimeout(graceTimer);
53
+ };
54
+
55
+ if (child.stdout) {
56
+ child.stdout.setEncoding('utf8');
57
+ child.stdout.on('data', (chunk) => {
58
+ stdout += chunk;
59
+ });
60
+ }
61
+ if (child.stderr) {
62
+ child.stderr.setEncoding('utf8');
63
+ child.stderr.on('data', (chunk) => {
64
+ stderr += chunk;
65
+ });
66
+ }
67
+
68
+ killTimer = setTimeout(() => {
69
+ // Ask nicely first, then force-kill after a short grace period.
70
+ try {
71
+ child.kill('SIGTERM');
72
+ } catch {
73
+ // ignore
74
+ }
75
+ graceTimer = setTimeout(() => {
76
+ try {
77
+ child.kill('SIGKILL');
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }, 2000);
82
+ }, timeoutMs);
83
+
84
+ child.on('error', (err) => {
85
+ if (settled) return;
86
+ settled = true;
87
+ clearTimers();
88
+ reject(err);
89
+ });
90
+
91
+ child.on('close', (code) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimers();
95
+ if (code !== 0) {
96
+ const detail = stderr.trim() || command;
97
+ reject(new Error(`cli source failed (exit ${code}): ${detail}`));
98
+ return;
99
+ }
100
+ resolve({ stdout, stderr });
101
+ });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Parse a CLI stdout string into an array of records (see fetchCli docs).
107
+ *
108
+ * @param {string} stdout - Raw stdout from the command.
109
+ * @returns {Array<object>}
110
+ */
111
+ function parseCliStdout(stdout) {
112
+ const trimmed = String(stdout).trim();
113
+ if (trimmed === '') return [];
114
+
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(trimmed);
118
+ } catch {
119
+ // Not JSON: fall back to line-based parsing.
120
+ return trimmed
121
+ .split('\n')
122
+ .map((line) => line.trim())
123
+ .filter((line) => line !== '')
124
+ .map((line) => ({ text: line, line, value: line }));
125
+ }
126
+
127
+ if (Array.isArray(parsed)) {
128
+ return parsed.map((el) =>
129
+ isPlainObject(el) ? el : { value: el, text: String(el) }
130
+ );
131
+ }
132
+ if (isPlainObject(parsed)) {
133
+ return [parsed];
134
+ }
135
+ return [{ value: parsed, text: String(parsed) }];
136
+ }
137
+
138
+ /**
139
+ * True for non-null, non-array objects.
140
+ *
141
+ * @param {*} value
142
+ * @returns {boolean}
143
+ */
144
+ function isPlainObject(value) {
145
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
146
+ }
147
+
148
+ export default fetchCli;