contextspin 0.6.2 → 0.6.4

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.2",
3
+ "version": "0.6.4",
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
@@ -30,6 +30,7 @@ import {
30
30
  uninstallStatusline,
31
31
  uninstallAllStatuslines,
32
32
  } from './inject/statusline.js';
33
+ import { addSessionStartHook, removeSessionStartHook } from './inject/hook.js';
33
34
  import { installPatcher, restorePatcher } from './inject/patcher.js';
34
35
  import { detectSources } from './detect.js';
35
36
 
@@ -476,88 +477,6 @@ async function runUninject(opts = {}) {
476
477
  }
477
478
  }
478
479
 
479
- /**
480
- * The SessionStart hook command the `install` flow wires into the user settings
481
- * so ContextSpin self-heals every session (the curl install replicates what the
482
- * Claude Code plugin's hook does, without the marketplace). Runs from a neutral
483
- * dir so npx never resolves a confused local package (Exit 127).
484
- */
485
- const SESSIONSTART_HOOK_CMD =
486
- 'cd /tmp && npx --yes contextspin ensure >/dev/null 2>&1; exit 0';
487
-
488
- /**
489
- * Read+parse a JSON file, returning a fallback on any read/parse error.
490
- * @param {string} filePath
491
- * @param {*} fallback
492
- * @returns {*}
493
- */
494
- function readJsonSafeSync(filePath, fallback) {
495
- try {
496
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
497
- } catch {
498
- return fallback;
499
- }
500
- }
501
-
502
- /**
503
- * Whether a SessionStart entry already runs ContextSpin (so install is idempotent).
504
- * @param {*} entry
505
- * @returns {boolean}
506
- */
507
- function entryRunsContextspin(entry) {
508
- return !!(
509
- entry &&
510
- Array.isArray(entry.hooks) &&
511
- entry.hooks.some(
512
- (h) => h && typeof h.command === 'string' && h.command.includes('contextspin'),
513
- )
514
- );
515
- }
516
-
517
- /**
518
- * Wire a ContextSpin SessionStart hook into the user ~/.claude/settings.json so
519
- * the daemon + statusline self-heal every session. JSON-merge (preserves every
520
- * other key and any existing hooks). Idempotent.
521
- * @returns {boolean} true if the hook was added (false if already present).
522
- */
523
- function addSessionStartHook() {
524
- fs.mkdirSync(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
525
- const settings = readJsonSafeSync(CLAUDE_SETTINGS_PATH, {});
526
- const obj = settings && typeof settings === 'object' ? settings : {};
527
- obj.hooks = obj.hooks && typeof obj.hooks === 'object' ? obj.hooks : {};
528
- const arr = Array.isArray(obj.hooks.SessionStart) ? obj.hooks.SessionStart : [];
529
- if (arr.some(entryRunsContextspin)) return false;
530
- arr.push({
531
- matcher: '',
532
- hooks: [{ type: 'command', command: SESSIONSTART_HOOK_CMD, timeout: 15 }],
533
- });
534
- obj.hooks.SessionStart = arr;
535
- fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(obj, null, 2));
536
- return true;
537
- }
538
-
539
- /**
540
- * Remove any ContextSpin SessionStart hook from the user settings (best-effort,
541
- * JSON-merge). Prunes empty containers.
542
- * @returns {boolean} true if a hook was removed.
543
- */
544
- function removeSessionStartHook() {
545
- const settings = readJsonSafeSync(CLAUDE_SETTINGS_PATH, null);
546
- if (!settings || typeof settings !== 'object' || !settings.hooks) return false;
547
- const arr = Array.isArray(settings.hooks.SessionStart)
548
- ? settings.hooks.SessionStart
549
- : [];
550
- const kept = arr.filter((e) => !entryRunsContextspin(e));
551
- if (kept.length === arr.length) return false;
552
- if (kept.length > 0) settings.hooks.SessionStart = kept;
553
- else delete settings.hooks.SessionStart;
554
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
555
- delete settings.hooks;
556
- }
557
- fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
558
- return true;
559
- }
560
-
561
480
  /**
562
481
  * One-shot install (the `curl | bash` entrypoint): wire the SessionStart hook so
563
482
  * ContextSpin self-heals each session, then run ensure (config + statusline +
@@ -565,7 +484,7 @@ function removeSessionStartHook() {
565
484
  * @returns {Promise<void>}
566
485
  */
567
486
  async function runInstall() {
568
- const addedHook = addSessionStartHook();
487
+ const addedHook = addSessionStartHook(readVersion());
569
488
  await runEnsure();
570
489
  console.log('');
571
490
  console.log(
package/src/config.js CHANGED
@@ -34,8 +34,14 @@ export const LOG_PATH = path.join(STATE_DIR, "daemon.log");
34
34
  /** Path to the generated statusline bash wrapper. */
35
35
  export const STATUSLINE_SH = path.join(STATE_DIR, "statusline.sh");
36
36
 
37
- /** Path to the generated statusline Node render script. */
38
- export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.js");
37
+ /**
38
+ * Path to the generated statusline Node render script. Uses the `.mjs` extension
39
+ * so Node treats it as ESM regardless of version — the script lives in STATE_DIR
40
+ * (no package.json), and Node 18 has no automatic ESM detection, so a plain
41
+ * `.js` would fail to parse its `import` statements ("Cannot use import statement
42
+ * outside a module").
43
+ */
44
+ export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.mjs");
39
45
 
40
46
  /**
41
47
  * Path to the recorded prior statusLine commands (captured when we wrap an
@@ -0,0 +1,105 @@
1
+ // src/inject/hook.js — manage ContextSpin's SessionStart hook in the user
2
+ // ~/.claude/settings.json. Used by `contextspin install` / `uninstall` (the curl
3
+ // flow) so ContextSpin self-heals every session without the plugin/marketplace.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { CLAUDE_SETTINGS_PATH } from "../config.js";
8
+
9
+ /**
10
+ * Read+parse a JSON file, returning a fallback on any read/parse error.
11
+ * @param {string} filePath
12
+ * @param {*} fallback
13
+ * @returns {*}
14
+ */
15
+ function readJsonSafeSync(filePath, fallback) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Build the SessionStart hook command, pinned to an EXACT version (never
25
+ * `@latest` or a range) so a future release never runs without a deliberate
26
+ * re-install. Runs from a neutral dir so npx can't resolve a confused local
27
+ * package (Exit 127).
28
+ * @param {string} version - The exact package version to pin (e.g. "0.6.3").
29
+ * @returns {string}
30
+ */
31
+ export function sessionStartHookCmd(version) {
32
+ return `cd /tmp && npx --yes contextspin@${version} ensure >/dev/null 2>&1; exit 0`;
33
+ }
34
+
35
+ /**
36
+ * Whether a SessionStart entry already runs ContextSpin (any version).
37
+ * @param {*} entry
38
+ * @returns {boolean}
39
+ */
40
+ export function entryRunsContextspin(entry) {
41
+ return !!(
42
+ entry &&
43
+ Array.isArray(entry.hooks) &&
44
+ entry.hooks.some(
45
+ (h) => h && typeof h.command === "string" && h.command.includes("contextspin"),
46
+ )
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Upsert the ContextSpin SessionStart hook into the user settings, pinned to
52
+ * `version`. JSON-merge (preserves every other key and any non-ours hooks).
53
+ * Drops any prior ContextSpin entry (e.g. an older pinned version) so
54
+ * re-installing a newer version re-pins cleanly. Idempotent when already current.
55
+ * @param {string} version
56
+ * @param {string} [settingsPath=CLAUDE_SETTINGS_PATH]
57
+ * @returns {boolean} true if the file was changed (added or re-pinned).
58
+ */
59
+ export function addSessionStartHook(version, settingsPath = CLAUDE_SETTINGS_PATH) {
60
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
61
+ const settings = readJsonSafeSync(settingsPath, {});
62
+ const obj = settings && typeof settings === "object" ? settings : {};
63
+ obj.hooks = obj.hooks && typeof obj.hooks === "object" ? obj.hooks : {};
64
+ const arr = Array.isArray(obj.hooks.SessionStart) ? obj.hooks.SessionStart : [];
65
+
66
+ const desired = sessionStartHookCmd(version);
67
+ const existing = arr.find(entryRunsContextspin);
68
+ const alreadyCurrent =
69
+ existing &&
70
+ Array.isArray(existing.hooks) &&
71
+ existing.hooks.some((h) => h && h.command === desired);
72
+ if (alreadyCurrent) return false;
73
+
74
+ const others = arr.filter((e) => !entryRunsContextspin(e));
75
+ others.push({
76
+ matcher: "",
77
+ hooks: [{ type: "command", command: desired, timeout: 15 }],
78
+ });
79
+ obj.hooks.SessionStart = others;
80
+ fs.writeFileSync(settingsPath, JSON.stringify(obj, null, 2));
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Remove any ContextSpin SessionStart hook from the user settings (JSON-merge,
86
+ * best-effort). Prunes empty containers.
87
+ * @param {string} [settingsPath=CLAUDE_SETTINGS_PATH]
88
+ * @returns {boolean} true if a hook was removed.
89
+ */
90
+ export function removeSessionStartHook(settingsPath = CLAUDE_SETTINGS_PATH) {
91
+ const settings = readJsonSafeSync(settingsPath, null);
92
+ if (!settings || typeof settings !== "object" || !settings.hooks) return false;
93
+ const arr = Array.isArray(settings.hooks.SessionStart)
94
+ ? settings.hooks.SessionStart
95
+ : [];
96
+ const kept = arr.filter((e) => !entryRunsContextspin(e));
97
+ if (kept.length === arr.length) return false;
98
+ if (kept.length > 0) settings.hooks.SessionStart = kept;
99
+ else delete settings.hooks.SessionStart;
100
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
101
+ delete settings.hooks;
102
+ }
103
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
104
+ return true;
105
+ }