contextspin 0.6.0 → 0.6.3

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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # ContextSpin
2
2
 
3
- Replace the Claude Code spinner / status bar text with live org context meetings, Slack mentions, CI failures, incidents, review queues — pulled from tools you already run.
3
+ Live context in your Claude Code **status bar** weather, the top Hacker News story, PRs awaiting your review, CI failures, incidents, meetings — pulled from tools you already run. Install in one line; the bar is never empty.
4
+
5
+ ```bash
6
+ curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
7
+ ```
4
8
 
5
9
  ## Key principle: ContextSpin does NOT fetch data
6
10
 
@@ -33,15 +37,14 @@ ContextSpin polls those sources on a schedule, formats whatever they return into
33
37
  │ read cache
34
38
 
35
39
  ┌─────────────────────────────────────────────────────────┐
36
- │ INJECTOR
40
+ │ INJECTOR (statusline — non-destructive, composed)
37
41
  │ statusline ──► ~/.contextspin/statusline.sh │
38
- patches ~/.claude/settings.json
39
- │ patcher ──► rewrites spinner words in the binary
40
- │ (EXPERIMENTAL) │
42
+ composed into ~/.claude/settings.json
43
+ │ patcher ──► rewrites spinner words (EXPERIMENTAL)
41
44
  └───────────────────────────┬─────────────────────────────┘
42
45
 
43
46
 
44
- Claude Code spinner / status bar shows one snippet
47
+ Claude Code status bar shows one snippet
45
48
  ```
46
49
 
47
50
  The daemon and the injector are decoupled by the cache file: the daemon writes snippets, the injector reads them. Each runs on its own clock.
@@ -170,7 +173,7 @@ ContextSpin reads one JSON file: `~/.contextspin.json` (override with the `CONTE
170
173
 
171
174
  | Field | Type | Format / values | Default | Meaning |
172
175
  |-------|------|-----------------|---------|---------|
173
- | `sources` | array | non-empty | | List of sources to poll. Required. |
176
+ | `sources` | array | | `[]` | Sources to poll. May be empty (the bar then shows the built-in defaults). |
174
177
  | `sources[].type` | string | `mcp` \| `cli` \| `http` | — | Source kind. Required. |
175
178
  | `sources[].tool` | string | tool name or `mcp__server__tool` | — | Required for `mcp`. |
176
179
  | `sources[].command` | string | shell command | — | Required for `cli`. |
@@ -183,6 +186,7 @@ ContextSpin reads one JSON file: `~/.contextspin.json` (override with the `CONTE
183
186
  | `injection.mode` | string | `statusline` \| `patcher` \| `both` | `statusline` | How snippets reach the UI. |
184
187
  | `injection.refresh` | number | seconds | `30` | Daemon poll interval and status-line refresh interval (seconds). |
185
188
  | `injection.maxVisible` | number | count | `5` | Global cap on snippets held in the cache. |
189
+ | `injection.style` | boolean | — | `true` | Render the line in a styled box (cyan bars + italic). Set `false` for plain text. |
186
190
  | `snippets.deduplication` | boolean | — | `true` | Drop snippets with duplicate text when merging. |
187
191
  | `snippets.cooldownAfterShown` | number | count | `3` | A snippet stops being eligible once shown this many times. |
188
192
  | `snippets.priorityOrder` | string[] | source labels | `[]` | Earlier labels sort first (case-insensitive); unlisted sort last. |
@@ -207,9 +211,9 @@ This is the supported path. It uses Claude Code's official [status line](https:/
207
211
 
208
212
  1. Write `~/.contextspin/statusline-render.js` — a self-contained script that drains stdin (so Claude Code's piped JSON can't cause `EPIPE`), reads the cache, picks the eligible snippet with the lowest `shownCount` (then most recent), increments its count, writes the cache back, and prints that one line. Any error exits cleanly with no output, so it can never break your status bar.
209
213
  2. Write `~/.contextspin/statusline.sh` — a `0755` bash wrapper that `exec`s the render script.
210
- 3. Patch `~/.claude/settings.json` to set `statusLine` to `{ type: "command", command: "<statusline.sh>", padding: 0, refreshInterval: <refresh> }` (refresh is in **seconds**). If you already had a different status line, it is backed up to `~/.claude/settings.json.contextspin.bak` first.
214
+ 3. Point `statusLine` at that wrapper (refresh in **seconds**), **non-destructively**: any status line you already had is preserved and *composed* — the render script runs your prior command and prints its output **above** the ContextSpin line. Scope-aware: in a project (when `CLAUDE_PROJECT_DIR` is set) it writes the gitignored `<project>/.claude/settings.local.json`, which outranks a repo's tracked `settings.json`, so a project's own status line can't shadow ContextSpin.
211
215
 
212
- Reverse it with `contextspin uninject` (restores your previous status line if a backup exists).
216
+ Reverse it with `contextspin uninject` (this scope) or `contextspin uninstall` (every scope it ever wired, plus the hook and daemon).
213
217
 
214
218
  ### `patcher` (EXPERIMENTAL — binary patching)
215
219
 
@@ -258,13 +262,16 @@ Restore the originals with `contextspin uninject --mode patcher` (or `inject --m
258
262
 
259
263
  | Command | What it does |
260
264
  |---------|--------------|
261
- | `contextspin setup [--yes]` | Create `~/.contextspin.json` (interactive, or from the bundled example with `--yes` / non-TTY). |
265
+ | `contextspin install` | **One-shot install:** wire a self-healing SessionStart hook, create the config, wire the statusline, and start the daemon. (This is what the curl script runs.) |
266
+ | `contextspin uninstall` | **Full teardown:** remove the hook, restore your prior statusline in **every** scope it wired, and stop the daemon. |
267
+ | `contextspin setup [--yes]` | Create `~/.contextspin.json` (interactive, or a detected config with `--yes` / non-TTY). |
262
268
  | `contextspin start` | Start the detached polling daemon. |
263
269
  | `contextspin stop` | Stop the daemon. |
264
270
  | `contextspin restart` | Stop then start. |
265
271
  | `contextspin status` | Show daemon state and the current cached snippets (source, age, shown count). |
266
- | `contextspin inject [--mode <m>]` | Install the injector. `<m>` overrides `injection.mode` (`statusline` / `patcher` / `both`). |
267
- | `contextspin uninject [--mode <m>]` | Reverse the injector. |
272
+ | `contextspin ensure` | Idempotent: create config + wire statusline + start daemon (run by the SessionStart hook each session). |
273
+ | `contextspin inject [--mode <m>]` | Install just the injector. `<m>` overrides `injection.mode` (`statusline` / `patcher` / `both`). |
274
+ | `contextspin uninject [--mode <m>]` | Reverse just the injector. |
268
275
  | `contextspin` *(no subcommand)* | `setup` if unconfigured, otherwise `start` then `inject`. |
269
276
 
270
277
  ## High-impact snippets
@@ -306,16 +313,18 @@ Three tiers, by how time-sensitive they are.
306
313
 
307
314
  ## Limitations
308
315
 
309
- - **MCP support is stdio-only.** ContextSpin discovers MCP servers from `~/.claude.json` (user and per-project scopes) and `.mcp.json`, and connects only to **stdio** servers (those with a `command`). HTTP / SSE / WebSocket MCP transports are not supported in Stage 1 — use a `cli` or `http` source instead. Plugin / managed scopes are ignored.
316
+ - **MCP support is stdio-only.** ContextSpin discovers MCP servers from `~/.claude.json` (user and per-project scopes) and `.mcp.json`, and connects only to **stdio** servers (those with a `command`). HTTP / SSE / WebSocket MCP transports are not supported — use a `cli` or `http` source instead. Plugin / managed scopes are ignored.
310
317
  - **OAuth-based claude.ai connectors are not reachable.** App-connected connectors (Slack, Notion, etc. linked through claude.ai) authenticate via OAuth tokens stored in the OS keychain. A standalone background daemon has no access to those tokens, so it cannot drive those connectors. Use the corresponding CLI (`gh`, `slack` CLI…) or HTTP endpoint, or a locally-configured stdio MCP server, instead.
311
318
  - **The status line shows one rotating snippet** at a time, honoring `cooldownAfterShown` so the same item doesn't repeat indefinitely.
312
319
  - **The patcher is experimental** and is **overwritten by every Claude Code update**. Treat it as best-effort; the statusline mode is the supported path.
313
320
 
314
- ## Roadmap
321
+ ## Zero-config defaults (never an empty bar)
322
+
323
+ A fresh install needs no setup:
315
324
 
316
- - **Stage 1 (now):** stdio MCP / CLI / HTTP sources, polling daemon + cache, statusline injection, experimental binary patcher, the CLI above.
317
- - **Stage 2 (polish):** quality-of-life improvementsbetter source discovery, richer setup wizard, more diagnostics.
318
- - **Stage 3 (`.plugin`):** package ContextSpin as a first-class Claude Code plugin.
325
+ - The config is seeded with a **no-credentials starter pack** — local weather, a dad joke, and the top Hacker News story so real snippets appear within seconds.
326
+ - When the cache is empty or every snippet is exhausted, the renderer falls back to **built-in defaults** (jokes + "ask `/contextspin`…" tips) that rotate, so the bar is never blank even offline or before the first poll.
327
+ - A Claude Code **plugin** is also available (the [`mannutech` marketplace](https://github.com/mannutech/claude-plugins)) for those who prefer installing that way — it wraps this same package.
319
328
 
320
329
  ## References
321
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
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
@@ -25,7 +25,11 @@ import {
25
25
  isDaemonRunning,
26
26
  readCache,
27
27
  } from './daemon.js';
28
- import { installStatusline, uninstallStatusline } from './inject/statusline.js';
28
+ import {
29
+ installStatusline,
30
+ uninstallStatusline,
31
+ uninstallAllStatuslines,
32
+ } from './inject/statusline.js';
29
33
  import { installPatcher, restorePatcher } from './inject/patcher.js';
30
34
  import { detectSources } from './detect.js';
31
35
 
@@ -475,11 +479,17 @@ async function runUninject(opts = {}) {
475
479
  /**
476
480
  * The SessionStart hook command the `install` flow wires into the user settings
477
481
  * so ContextSpin self-heals every session (the curl install replicates what the
478
- * Claude Code plugin's hook does, without the marketplace). Runs from a neutral
479
- * dir so npx never resolves a confused local package (Exit 127).
482
+ * Claude Code plugin's hook does, without the marketplace).
483
+ *
484
+ * The hook is pinned to the EXACT version doing the install (never `@latest` or a
485
+ * range), so a future release never runs on the user's machine without them
486
+ * deliberately re-installing. Runs from a neutral dir so npx never resolves a
487
+ * confused local package (Exit 127).
488
+ * @returns {string}
480
489
  */
481
- const SESSIONSTART_HOOK_CMD =
482
- 'cd /tmp && npx --yes contextspin ensure >/dev/null 2>&1; exit 0';
490
+ function sessionStartHookCmd() {
491
+ return `cd /tmp && npx --yes contextspin@${readVersion()} ensure >/dev/null 2>&1; exit 0`;
492
+ }
483
493
 
484
494
  /**
485
495
  * Read+parse a JSON file, returning a fallback on any read/parse error.
@@ -522,12 +532,24 @@ function addSessionStartHook() {
522
532
  const obj = settings && typeof settings === 'object' ? settings : {};
523
533
  obj.hooks = obj.hooks && typeof obj.hooks === 'object' ? obj.hooks : {};
524
534
  const arr = Array.isArray(obj.hooks.SessionStart) ? obj.hooks.SessionStart : [];
525
- if (arr.some(entryRunsContextspin)) return false;
526
- arr.push({
535
+
536
+ const desired = sessionStartHookCmd();
537
+ const existing = arr.find(entryRunsContextspin);
538
+ const alreadyCurrent =
539
+ existing &&
540
+ Array.isArray(existing.hooks) &&
541
+ existing.hooks.some((h) => h && h.command === desired);
542
+ // Already exactly this command (same pinned version) — nothing to do.
543
+ if (alreadyCurrent) return false;
544
+
545
+ // Upsert: drop any prior ContextSpin entry (e.g. an older pinned version) and
546
+ // add the current one, so re-installing a newer version re-pins cleanly.
547
+ const others = arr.filter((e) => !entryRunsContextspin(e));
548
+ others.push({
527
549
  matcher: '',
528
- hooks: [{ type: 'command', command: SESSIONSTART_HOOK_CMD, timeout: 15 }],
550
+ hooks: [{ type: 'command', command: desired, timeout: 15 }],
529
551
  });
530
- obj.hooks.SessionStart = arr;
552
+ obj.hooks.SessionStart = others;
531
553
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(obj, null, 2));
532
554
  return true;
533
555
  }
@@ -580,13 +602,21 @@ async function runInstall() {
580
602
  */
581
603
  async function runUninstall() {
582
604
  const removedHook = removeSessionStartHook();
583
- await uninstallStatusline({});
605
+ // Tear down EVERY scope we wired (user + each project the hook touched), not
606
+ // just the user scope — otherwise project-scoped wirings keep rendering.
607
+ const results = await uninstallAllStatuslines();
608
+ const removed = results.filter((r) => r && r.removed);
584
609
  await stopDaemon();
585
610
  console.log(
586
- removedHook
587
- ? 'ContextSpin uninstalled (hook removed, statusline restored, daemon stopped).'
588
- : 'ContextSpin hook not found; statusline restored and daemon stopped.',
611
+ `ContextSpin uninstalled: removed the statusline from ${removed.length} ` +
612
+ `scope${removed.length === 1 ? '' : 's'}, ` +
613
+ `${removedHook ? 'dropped the SessionStart hook, ' : ''}stopped the daemon.`,
589
614
  );
615
+ const projectScopes = removed.filter((r) => r.scope === 'project');
616
+ if (projectScopes.length > 0) {
617
+ console.log('Cleaned project statuslines:');
618
+ for (const r of projectScopes) console.log(` ${r.settingsPath}`);
619
+ }
590
620
  }
591
621
 
592
622
  /**
package/src/config.js CHANGED
@@ -47,6 +47,16 @@ export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.js");
47
47
  */
48
48
  export const PREV_STATUSLINE_PATH = path.join(STATE_DIR, "prev-statusline.json");
49
49
 
50
+ /**
51
+ * Path to the registry of every settings file ContextSpin has wired its
52
+ * statusLine into. A JSON array of scope KEYS ("" for the user scope, else an
53
+ * absolute realpath'd project dir). `installStatusline` appends to it; a full
54
+ * `uninstall` walks it so EVERY scope is torn down — not just the user scope.
55
+ * Without this, project-scoped wirings (written by the SessionStart hook per
56
+ * CLAUDE_PROJECT_DIR) would linger after uninstall.
57
+ */
58
+ export const WIRED_STATUSLINES_PATH = path.join(STATE_DIR, "wired-statuslines.json");
59
+
50
60
  /** Path to Claude Code's settings file (patched by the statusline injector). */
51
61
  export const CLAUDE_SETTINGS_PATH = path.join(HOME, ".claude", "settings.json");
52
62
 
package/src/daemon.js CHANGED
@@ -5,7 +5,7 @@ import fsp from "node:fs/promises";
5
5
  import process from "node:process";
6
6
  import { spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
- import { CACHE_PATH, STATE_DIR, PID_PATH, LOG_PATH, loadConfig } from "./config.js";
8
+ import { CACHE_PATH, STATE_DIR, PID_PATH, LOG_PATH, loadConfig, configExists } from "./config.js";
9
9
  import { runSource } from "./runner.js";
10
10
 
11
11
  /**
@@ -187,6 +187,15 @@ export async function runDaemonLoop(opts = {}) {
187
187
  const runtime = { lastRun: {}, buckets: {}, snippets: [] };
188
188
  // eslint-disable-next-line no-constant-condition
189
189
  while (true) {
190
+ // Self-exit if the config has been deleted (e.g. the user ran
191
+ // `contextspin uninstall` or removed ~/.contextspin.json by hand). Without
192
+ // this the daemon would keep polling stale sources and writing the cache
193
+ // forever, so the statusline would still show text after a teardown.
194
+ if (!configExists(opts.configPath)) {
195
+ console.log("contextspin config removed — daemon shutting down.");
196
+ shutdown();
197
+ return;
198
+ }
190
199
  try {
191
200
  const snippets = await pollOnce(config, runtime);
192
201
  await writeCache({ updatedAt: nowISO(), snippets });
@@ -33,6 +33,7 @@ import {
33
33
  CONFIG_PATH,
34
34
  CLAUDE_SETTINGS_PATH,
35
35
  DEFAULT_SNIPPETS,
36
+ WIRED_STATUSLINES_PATH,
36
37
  } from "../config.js";
37
38
 
38
39
  /**
@@ -460,6 +461,29 @@ async function writePrevMap(map) {
460
461
  await writeJsonAtomic(PREV_STATUSLINE_PATH, map);
461
462
  }
462
463
 
464
+ /**
465
+ * Read the wired-statuslines registry: an array of scope KEYS ("" for user
466
+ * scope, else an absolute project dir). Tolerates a missing/bad file (-> []).
467
+ * @returns {string[]}
468
+ */
469
+ function readWiredList() {
470
+ const raw = readJsonSafeSync(WIRED_STATUSLINES_PATH, null);
471
+ return Array.isArray(raw) ? raw.filter((k) => typeof k === "string") : [];
472
+ }
473
+
474
+ /**
475
+ * Record a scope KEY in the wired-statuslines registry (idempotent).
476
+ * @param {string} key - "" for user scope, else an absolute project dir.
477
+ * @returns {Promise<void>}
478
+ */
479
+ async function addWired(key) {
480
+ const list = readWiredList();
481
+ if (!list.includes(key)) {
482
+ list.push(key);
483
+ await writeJsonAtomic(WIRED_STATUSLINES_PATH, list);
484
+ }
485
+ }
486
+
463
487
  /**
464
488
  * Resolve the statusLine command currently configured in a settings file (if
465
489
  * any), ignoring our own wrapper. Returns null when the file has no usable
@@ -625,6 +649,10 @@ export async function installStatusline(config, opts = {}) {
625
649
 
626
650
  await writeJsonAtomic(targetPath, settingsObj);
627
651
 
652
+ // Record this scope in the wired registry so a later `uninstall` can tear down
653
+ // EVERY scope we touched (not just the user scope).
654
+ await addWired(key);
655
+
628
656
  if (composed) {
629
657
  const priorCmd = prior ? prior.command : (map[key] && map[key].command);
630
658
  warning =
@@ -772,3 +800,42 @@ export async function uninstallStatusline(opts = {}) {
772
800
  note: "Removed the ContextSpin statusLine entry.",
773
801
  };
774
802
  }
803
+
804
+ /**
805
+ * Tear down EVERY statusline scope ContextSpin has wired, by walking the wired
806
+ * registry (plus the user scope, always). This is what a full `uninstall` should
807
+ * call: project-scoped wirings written by the SessionStart hook (one per
808
+ * CLAUDE_PROJECT_DIR) are otherwise invisible to a user-scope-only uninstall and
809
+ * would keep rendering the ContextSpin line after removal.
810
+ *
811
+ * Clears the registry when done. Never throws — a failure for one scope is
812
+ * captured in that scope's result and the walk continues.
813
+ *
814
+ * @returns {Promise<UninstallStatuslineResult[]>}
815
+ */
816
+ export async function uninstallAllStatuslines() {
817
+ // Always include the user scope (""), plus every recorded project key.
818
+ const keys = Array.from(new Set(["", ...readWiredList()]));
819
+ const results = [];
820
+ for (const key of keys) {
821
+ const projectDir = key === "" ? undefined : key;
822
+ try {
823
+ results.push(await uninstallStatusline({ projectDir }));
824
+ } catch (err) {
825
+ results.push({
826
+ removed: false,
827
+ restored: false,
828
+ settingsPath: key,
829
+ scope: projectDir ? "project" : "user",
830
+ note: `failed: ${err && err.message ? err.message : String(err)}`,
831
+ });
832
+ }
833
+ }
834
+ // Registry is consumed — drop it.
835
+ try {
836
+ await fsp.unlink(WIRED_STATUSLINES_PATH);
837
+ } catch {
838
+ // best effort
839
+ }
840
+ return results;
841
+ }