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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.1.2",
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
- * Write the bundled example config to the destination path. Confirms before
95
- * overwriting an existing file unless `force` is true.
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: drop the example config unless one already exists.
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 writeExampleConfig(CONFIG_PATH, false);
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
- const sources = [];
158
+ let sources = [];
173
159
  const seedAns = (
174
- await rl.question('Seed a couple of safe starter sources? (Y/n) ')
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
- // Safe starters: read-only `gh` queries that do nothing harmful.
180
- sources.push({
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 example)')
435
- .option('--yes', 'skip prompts and write the example config')
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
- if (!Array.isArray(config.sources) || config.sources.length === 0) {
139
- throw new Error('Invalid config: "sources" must be a non-empty array.');
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
- const tmp = CACHE_PATH + ".tmp";
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;
@@ -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, reads+discards stdin)
3
- // and wires it into ~/.claude/settings.json under the camelCase `statusLine` key.
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 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).
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 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).
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 config paths are baked into the script as string literals so the
34
- * generated file is fully self-contained and has no import dependencies.
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
- 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.
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
- /** Drain and discard stdin so Claude Code's JSON pipe never gets EPIPE. */
51
- function drainStdin() {
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
- stdin.on("end", () => resolve());
58
- stdin.on("close", () => resolve());
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(resolve, 250).unref?.();
89
+ setTimeout(finish, 250).unref?.();
62
90
  } catch {
63
- resolve();
91
+ finish();
64
92
  }
65
93
  });
66
94
  }
67
95
 
68
- /** Atomically replace a JSON file (write tmp then rename). */
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
- async function main() {
76
- await drainStdin();
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; // no output
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
- // 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);
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). If an existing
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) 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.
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 was overwritten (\`${existing.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 `statusLine` key.
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,