contextspin 0.6.4 → 0.7.1

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,336 +1,163 @@
1
1
  # ContextSpin
2
2
 
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.
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. One-line install, and the bar is never empty.
4
4
 
5
5
  ```bash
6
6
  curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
7
7
  ```
8
8
 
9
- ## Key principle: ContextSpin does NOT fetch data
9
+ Requires Node.js 18. MIT licensed. The only runtime dependency is [`commander`](https://www.npmjs.com/package/commander).
10
10
 
11
- ContextSpin is a **compositor and renderer, not a data layer.** It has no API clients, no auth flows, and no integrations of its own. Instead it **aggregates from sources you already have**:
11
+ ## It does NOT fetch data
12
12
 
13
- - **existing MCP servers** registered in your `~/.claude.json` / `.mcp.json`
14
- - **CLI tools** already installed and authenticated on your machine (`gh`, `kubectl`, `aws`, your own scripts…)
15
- - **HTTP endpoints** you can already reach (internal dashboards, status APIs)
13
+ ContextSpin is a **renderer, not a data layer** no API clients, no auth flows, no integrations of its own. It aggregates from sources you already have:
16
14
 
17
- ContextSpin polls those sources on a schedule, formats whatever they return into short one-line snippets, and injects the most relevant one into the Claude Code status bar. If a piece of data isn't reachable by a tool you already have, ContextSpin cannot show it — by design. The only runtime dependency is [`commander`](https://www.npmjs.com/package/commander).
15
+ - **MCP servers** registered in `~/.claude.json` / `.mcp.json` (stdio only)
16
+ - **CLI tools** already installed and authed (`gh`, `kubectl`, `glab`, your scripts…)
17
+ - **HTTP endpoints** you can already reach
18
18
 
19
- ## Architecture
19
+ It formats whatever they return into one-line snippets and shows the most relevant one. If a tool you have can't reach the data, ContextSpin can't show it — by design.
20
+
21
+ ## How it works (daemonless)
22
+
23
+ There is **no background process by default**. The statusline render is the engine — it serves the cached snippet instantly, and only when a source is past its cooldown does it spawn a detached one-shot refresh (lock-guarded so frequent renders never overlap). Nothing runs when you're not in Claude Code, so idle cost is zero.
20
24
 
21
25
  ```
22
- ┌─────────────────────────────────────────────────────────┐
23
- SOURCES (things you already have) │
24
- │ │
25
- │ mcp ──► stdio MCP servers from ~/.claude.json │
26
- cli ──► shell commands (gh, kubectl, scripts...)
27
- http ──► HTTP/JSON endpoints you can reach │
28
- └───────────────────────────┬─────────────────────────────┘
29
- poll on per-source cooldown
30
-
31
- ┌─────────────────────────────────────────────────────────┐
32
- │ POLLING DAEMON (detached background process)
33
- │ • runs each source, applies filter + format │
34
- │ • merges / dedups / prioritizes snippets │
35
- │ • writes ~/.contextspin-cache.json (atomic) │
36
- └───────────────────────────┬─────────────────────────────┘
37
- │ read cache
38
-
39
- ┌─────────────────────────────────────────────────────────┐
40
- │ INJECTOR (statusline — non-destructive, composed) │
41
- │ statusline ──► ~/.contextspin/statusline.sh │
42
- │ composed into ~/.claude/settings.json │
43
- │ patcher ──► rewrites spinner words (EXPERIMENTAL) │
44
- └───────────────────────────┬─────────────────────────────┘
45
-
46
-
47
- Claude Code status bar shows one snippet
26
+ Claude Code draws the status bar
27
+
28
+
29
+ RENDER (~/.contextspin/statusline.mjs)
30
+ 1. read the cache, print one snippet NOW (stale is fine) ──► status bar
31
+ 2. if a source is due and no refresh is in flight:
32
+
33
+ (detached, non-blocking)
34
+ REFRESH (one-shot, src/refresh-entry.js)
35
+ • run each DUE source, format, merge/dedup/prioritize, record lastRun
36
+ write ~/.contextspin-cache.json (atomic)
48
37
  ```
49
38
 
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.
39
+ This is *stale-while-revalidate*: the bar is always fast, freshness catches up in the background. A legacy always-on daemon is still available behind `injection.daemonless: false` only worth it for stdio MCP sources, where a persistent connection beats per-render handshakes.
51
40
 
52
41
  ## Install
53
42
 
54
- Requires Node.js >= 18 (ContextSpin uses the built-in global `fetch`).
55
-
56
- **One line — that's it:**
57
-
58
43
  ```bash
59
44
  curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
60
45
  ```
61
46
 
62
- This wires a SessionStart hook into `~/.claude/settings.json` (so ContextSpin self-heals every session), seeds a no-credentials starter pack (weather, a dad joke, the top Hacker News story), and wires your status bar non-destructively. Restart Claude Code and you'll see live snippets, never an empty bar.
47
+ This wires a SessionStart hook into `~/.claude/settings.json` (so it self-heals each session), seeds a no-credentials starter pack (weather, a dad joke, the top HN story), and wires your status bar **non-destructively** (any existing status line is preserved and composed above ours). Restart Claude Code to see it.
63
48
 
64
- Prefer to do it yourself? `npx contextspin install` does the same thing. To remove everything: `npx contextspin uninstall`.
49
+ `npx contextspin install` does the same. `npx contextspin uninstall` removes everything. `npx contextspin status` shows the current snippets.
65
50
 
66
- <details>
67
- <summary>Manual / advanced setup</summary>
51
+ ## Sources
68
52
 
69
- ```bash
70
- npx contextspin setup # create a config (add --yes to skip prompts)
71
- npx contextspin start # start the background polling daemon
72
- npx contextspin inject # wire snippets into the status bar
73
- ```
74
-
75
- Running `npx contextspin` with **no subcommand** is a shortcut: if no config exists it runs `setup`, otherwise it runs `start` followed by `inject` using the mode from your config.
53
+ Every source returns a list of records. Each record is optionally `filter`ed, then rendered with `format` using `{{ field }}` templating — dotted/bracketed paths work (`{{ results[0].value }}`), `{{ env.NAME }}` reads an environment variable, unknown fields render empty.
76
54
 
77
- </details>
78
-
79
- Check what's happening at any time:
80
-
81
- ```bash
82
- npx contextspin status
83
- ```
84
-
85
- ## Source types
86
-
87
- Every source produces a list of records. Each record is run through an optional `filter`, then rendered with `format` using **double-curly-brace** field templating (`{{ field }}`). Inner whitespace is allowed. Dotted and bracketed paths work (`{{ results[0].value }}`), and `{{ env.NAME }}` resolves to an environment variable. Unknown fields render as the empty string.
88
-
89
- ### `mcp` — call a tool on an existing MCP server
90
-
91
- Calls a tool on a **stdio** MCP server discovered from your Claude config. ContextSpin speaks JSON-RPC 2.0 over the server's stdin/stdout itself — no SDK, no extra dependency.
55
+ **`mcp`** — call a tool on a stdio MCP server discovered from your Claude config (JSON-RPC over stdin/stdout, no SDK):
92
56
 
93
57
  ```json
94
- {
95
- "type": "mcp",
96
- "tool": "slack_search_public",
97
- "args": { "query": "mentions:me is:unread" },
98
- "format": "Slack: {{ text }}",
99
- "label": "Slack",
100
- "cooldown": 300,
101
- "maxSnippets": 2
102
- }
58
+ { "type": "mcp", "tool": "slack_search_public", "args": { "query": "mentions:me is:unread" },
59
+ "format": "Slack: {{ text }}", "label": "Slack", "cooldown": 300, "maxSnippets": 2 }
103
60
  ```
104
61
 
105
- - `tool` is required. You may pass a bare tool name (`slack_search_public`) or the fully-qualified `mcp__<server>__<tool>` form; the `mcp__<server>__` prefix is stripped to find the raw tool and to infer which server to use.
106
- - `server` is optional. If omitted and the tool name doesn't encode it, ContextSpin connects to each stdio server, lists its tools, and uses the first one that exposes the tool.
107
- - `args` is passed straight through as the tool-call arguments.
62
+ `tool` may be bare or `mcp__<server>__<tool>`; `server` is optional (otherwise the first stdio server exposing the tool is used).
108
63
 
109
- ### `cli` — run a shell command
64
+ **`cli`** — run a shell command (output parsed as a JSON array/object/primitive, or split into lines):
110
65
 
111
66
  ```json
112
- {
113
- "type": "cli",
114
- "command": "gh pr list --review-requested @me --json title,number --limit 3",
115
- "format": "PR #{{ number }} needs your review: {{ title }}",
116
- "label": "GitHub",
117
- "cooldown": 120,
118
- "maxSnippets": 3
119
- }
67
+ { "type": "cli", "command": "gh pr list --review-requested @me --json title,number --limit 3",
68
+ "format": "PR #{{ number }} needs review: {{ title }}", "label": "GitHub", "cooldown": 120, "maxSnippets": 3 }
120
69
  ```
121
70
 
122
- The command runs through your shell. Output parsing is forgiving:
123
-
124
- - a **JSON array** → each element becomes a record (objects kept as-is; primitives wrapped as `{ value, text }`)
125
- - a **JSON object** → a single record
126
- - a **JSON primitive** → `{ value, text }`
127
- - **anything else** → split into non-empty lines, each becoming `{ text, line, value }`
128
-
129
- A non-zero exit throws; a configurable timeout (default 15s) protects against hangs.
130
-
131
- ### `http` — fetch a JSON (or text) endpoint
71
+ **`http`** fetch a JSON or text endpoint:
132
72
 
133
73
  ```json
134
- {
135
- "type": "http",
136
- "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
74
+ { "type": "http", "url": "https://grafana.example.com/api/.../query?q=incidents",
137
75
  "headers": { "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}" },
138
- "jq": ".results[0].value",
139
- "format": "Grafana: {{ value }}",
140
- "label": "Grafana",
141
- "cooldown": 30,
142
- "maxSnippets": 1
143
- }
76
+ "jq": ".results[0].value", "format": "Grafana: {{ value }}", "label": "Grafana", "cooldown": 30 }
144
77
  ```
145
78
 
146
- - `url` and header values are interpolated, so you can inject secrets with `{{ env.X }}` instead of hard-coding them.
147
- - `method` defaults to `GET`; a `body` object is JSON-stringified with the right content-type.
148
- - `jq` accepts a **minimal jq subset**: identity `.`, dotted keys (`.a.b`), bracket indexing (`.a[0]`), iteration (`.[]`, `.a[]`), and left-to-right pipes (`a | b`). Unsupported expressions pass the data through unchanged rather than erroring.
79
+ `url` and headers are interpolated (use `{{ env.X }}` for secrets, never hard-code them). `jq` supports a minimal subset: identity, dotted keys, bracket indexing, iteration (`.[]`), and pipes.
149
80
 
150
81
  ## Configuration
151
82
 
152
- ContextSpin reads one JSON file: `~/.contextspin.json` (override with the `CONTEXTSPIN_CONFIG` environment variable). The cache lives at `~/.contextspin-cache.json` (override with `CONTEXTSPIN_CACHE`).
83
+ One JSON file, `~/.contextspin.json` (override with `CONTEXTSPIN_CONFIG`); cache at `~/.contextspin-cache.json` (override with `CONTEXTSPIN_CACHE`).
153
84
 
154
85
  ```json
155
86
  {
156
87
  "sources": [
157
88
  { "type": "cli", "command": "gh pr list --json title --limit 3", "format": "PR: {{ title }}" }
158
89
  ],
159
- "injection": {
160
- "mode": "statusline",
161
- "refresh": 30,
162
- "maxVisible": 5
163
- },
164
- "snippets": {
165
- "deduplication": true,
166
- "cooldownAfterShown": 3,
167
- "priorityOrder": ["incident", "ci", "slack", "calendar", "github", "jira"]
168
- }
90
+ "injection": { "mode": "statusline", "refresh": 30, "maxVisible": 5 },
91
+ "snippets": { "deduplication": true, "cooldownAfterShown": 3, "priorityOrder": ["incident", "ci", "github"] }
169
92
  }
170
93
  ```
171
94
 
172
- ### Field reference
173
-
174
- | Field | Type | Format / values | Default | Meaning |
175
- |-------|------|-----------------|---------|---------|
176
- | `sources` | array | | `[]` | Sources to poll. May be empty (the bar then shows the built-in defaults). |
177
- | `sources[].type` | string | `mcp` \| `cli` \| `http` | | Source kind. Required. |
178
- | `sources[].tool` | string | tool name or `mcp__server__tool` | | Required for `mcp`. |
179
- | `sources[].command` | string | shell command | | Required for `cli`. |
180
- | `sources[].url` | string | URL (templated) | | Required for `http`. |
181
- | `sources[].format` | string | `{{ field }}` template | — | One-line render template. Required. |
182
- | `sources[].filter` | string | `LEFT OP RIGHT` | | Optional. Keep a record only if it passes. See below. |
183
- | `sources[].label` | string | free text | derived | Shown as the snippet source. Derived if omitted: mcp→tool name, cli→first command token, http→hostname. |
184
- | `sources[].cooldown` | number | seconds | `300` | Minimum seconds between polls of this source. |
185
- | `sources[].maxSnippets` | number | count | `2` | Max snippets kept from one poll of this source. |
186
- | `injection.mode` | string | `statusline` \| `patcher` \| `both` | `statusline` | How snippets reach the UI. |
187
- | `injection.refresh` | number | seconds | `30` | Daemon poll interval and status-line refresh interval (seconds). |
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. |
190
- | `snippets.deduplication` | boolean | | `true` | Drop snippets with duplicate text when merging. |
191
- | `snippets.cooldownAfterShown` | number | count | `3` | A snippet stops being eligible once shown this many times. |
192
- | `snippets.priorityOrder` | string[] | source labels | `[]` | Earlier labels sort first (case-insensitive); unlisted sort last. |
193
-
194
- ### Filters
195
-
196
- `filter` is a **single, safe comparison** — no `eval`, no `Function`. The whole expression is interpolated against the record first, then parsed as `LEFT OP RIGHT` where `OP` is one of `==`, `!=`, `>=`, `<=`, `>`, `<`, or the word `includes`. Both numeric sides compare numerically; otherwise they compare as strings (`==` / `!=` are loose). `includes` is substring containment. With no operator, the result is truthy unless it's empty, `false`, or `0`.
95
+ | Field | Default | Meaning |
96
+ |-------|---------|---------|
97
+ | `sources[].type` | | `mcp` \| `cli` \| `http` (required) |
98
+ | `sources[].tool` / `command` / `url` | — | Required for `mcp` / `cli` / `http` respectively |
99
+ | `sources[].format` | | One-line `{{ field }}` template (required) |
100
+ | `sources[].filter` | | Keep a record only if it passes (see below) |
101
+ | `sources[].label` | derived | Snippet source label (mcp→tool, cli→first token, http→host) |
102
+ | `sources[].cooldown` | `300` | Min seconds between polls of this source |
103
+ | `sources[].maxSnippets` | `2` | Max snippets kept per poll |
104
+ | `injection.refresh` | `30` | Status-bar refresh interval, seconds |
105
+ | `injection.maxVisible` | `5` | Cap on snippets held in the cache |
106
+ | `injection.style` | `true` | Styled box (cyan bars + italic); `false` for plain text |
107
+ | `injection.daemonless` | `true` | Self-refreshing render; `false` for the legacy daemon |
108
+ | `injection.mode` | `statusline` | `statusline` \| `patcher` \| `both` |
109
+ | `snippets.deduplication` | `true` | Drop duplicate-text snippets when merging |
110
+ | `snippets.cooldownAfterShown` | `3` | A snippet stops showing after this many displays |
111
+ | `snippets.priorityOrder` | `[]` | Source labels sorted first (case-insensitive); rest last |
112
+
113
+ **Filters** are a single safe comparison (no `eval`): the expression is interpolated, then parsed as `LEFT OP RIGHT` where `OP` is `==`, `!=`, `>=`, `<=`, `>`, `<`, or `includes`. No `&&`/`||`.
197
114
 
198
115
  ```json
199
116
  { "filter": "{{ status }} == failure" }
200
117
  ```
201
118
 
202
- Only one comparison is supported — there is no `&&` / `||`.
203
-
204
- ## Injection modes
205
-
206
- ### `statusline` (recommended, official)
207
-
208
- This is the supported path. It uses Claude Code's official [status line](https://code.claude.com/docs/en/statusline) feature, so it survives Claude Code updates.
209
-
210
- `contextspin inject` (mode `statusline`) will:
211
-
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.
213
- 2. Write `~/.contextspin/statusline.sh` — a `0755` bash wrapper that `exec`s the render script.
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.
215
-
216
- Reverse it with `contextspin uninject` (this scope) or `contextspin uninstall` (every scope it ever wired, plus the hook and daemon).
217
-
218
- ### `patcher` (EXPERIMENTAL — binary patching)
219
-
220
- > ⚠️ **Experimental and fragile.** Inspired by [claude-depester](https://github.com/ominiverdi/claude-depester). It rewrites the hard-coded spinner words (`Flibbertigibbeting`, `Discombobulating`, …) inside the Claude Code binary/bundle with your live snippets.
221
-
222
- Key facts:
223
-
224
- - It is **length-preserving**: the replacement is padded with spaces so the file size never changes. If your snippets don't fit, words are trimmed until they do.
225
- - It works on both **text** (`cli.js`) and **compiled binary** installs, located by scanning the usual install paths and keeping only files that actually contain the marker word.
226
- - A **restart of Claude Code is required** for the patch to take effect.
227
- - **Claude Code updates overwrite the patch.** The installer also writes a wrapper at `~/.contextspin/cl.sh` that re-applies the patch and then `exec`s `claude`. After every Claude Code update you must re-patch — using that wrapper is the easiest way.
228
- - On macOS it makes a best-effort `codesign` re-sign after patching.
229
-
230
- Restore the originals with `contextspin uninject --mode patcher` (or `inject --mode both` / `uninject --mode both` to do both at once). A backup with the suffix `.contextspin.backup` is created before any install is touched.
231
-
232
- ## Daemon and cache
233
-
234
- `contextspin start` spawns a **detached** background process (the daemon). It writes its PID to `~/.contextspin/daemon.pid` and logs to `~/.contextspin/daemon.log`. The loop:
235
-
236
- 1. For each source whose `cooldown` has elapsed, runs it, applies the filter, formats records, and slices to `maxSnippets`.
237
- 2. Merges the fresh snippets into the existing set: preserves `shownCount` for matching text, optionally dedups, sorts by `priorityOrder` then by recency, and caps to `injection.maxVisible`.
238
- 3. Atomically writes the cache, then sleeps `injection.refresh` seconds.
239
-
240
- `stop` / `restart` manage the process; `status` reports whether it's running and lists the current snippets.
241
-
242
- ### Cache file format (`~/.contextspin-cache.json`)
119
+ ## Cache
243
120
 
244
121
  ```json
245
122
  {
246
123
  "updatedAt": "2026-06-17T09:00:00.000Z",
247
124
  "snippets": [
248
- {
249
- "text": "CI failing: build on main",
250
- "source": "CI",
251
- "sourceId": 2,
252
- "fetchedAt": "2026-06-17T09:00:00.000Z",
253
- "shownCount": 0
254
- }
255
- ]
125
+ { "text": "CI failing: build on main", "source": "CI", "sourceId": 2,
126
+ "fetchedAt": "2026-06-17T09:00:00.000Z", "shownCount": 0 }
127
+ ],
128
+ "meta": { "lastRun": { "2": 1781860451773 } }
256
129
  }
257
130
  ```
258
131
 
259
- `shownCount` is incremented by the status-line renderer each time a snippet is displayed; once it reaches `cooldownAfterShown` the snippet is no longer shown.
132
+ `shownCount` rises each time a snippet is shown; past `cooldownAfterShown` it's retired. `meta.lastRun` maps `sourceId → last poll (ms)` so the refresh honors per-source cooldowns across runs. When the cache is empty or every snippet is retired, the render rotates through built-in defaults (jokes + tips) — so the bar is never blank.
260
133
 
261
- ## CLI commands
134
+ ## CLI
262
135
 
263
136
  | Command | What it does |
264
137
  |---------|--------------|
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). |
268
- | `contextspin start` | Start the detached polling daemon. |
269
- | `contextspin stop` | Stop the daemon. |
270
- | `contextspin restart` | Stop then start. |
271
- | `contextspin status` | Show daemon state and the current cached snippets (source, age, shown count). |
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. |
275
- | `contextspin` *(no subcommand)* | `setup` if unconfigured, otherwise `start` then `inject`. |
276
-
277
- ## High-impact snippets
278
-
279
- Three tiers, by how time-sensitive they are.
280
-
281
- ### Tier 1 — time-sensitive (act in minutes)
282
-
283
- ```json
284
- { "type": "cli", "command": "gh run list --json status,name,headBranch --limit 5",
285
- "filter": "{{ status }} == failure",
286
- "format": "CI failing: {{ name }} on {{ headBranch }}", "label": "CI", "cooldown": 60, "maxSnippets": 2 }
287
- ```
288
-
289
- ```json
290
- { "type": "http", "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
291
- "headers": { "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}" },
292
- "jq": ".results[0].value", "format": "Grafana: {{ value }}", "label": "Grafana", "cooldown": 30, "maxSnippets": 1 }
293
- ```
138
+ | `install` | Wire the self-healing SessionStart hook, create config, wire the statusline (what the curl script runs). |
139
+ | `uninstall` | Remove the hook, restore your prior statusline in **every** scope, stop any daemon. |
140
+ | `status` | Show the engine and cached snippets. |
141
+ | `refresh` | Force a one-shot refresh of all due sources now. |
142
+ | `setup [--yes]` | Create `~/.contextspin.json` (interactive, or detected with `--yes`). |
143
+ | `ensure` | Idempotent create-config + wire-statusline (run by the hook each session). |
144
+ | `inject` / `uninject [--mode <m>]` | Install / reverse just the injector. |
145
+ | `start` / `stop` / `restart` | Manage the legacy daemon (only when `injection.daemonless: false`). |
294
146
 
295
- ### Tier 2 — ambient ops (good to know)
147
+ ## Statusline injection
296
148
 
297
- ```json
298
- { "type": "mcp", "tool": "slack_search_public", "args": { "query": "mentions:me is:unread" },
299
- "format": "Slack: {{ text }}", "label": "Slack", "cooldown": 300, "maxSnippets": 2 }
300
- ```
149
+ Uses Claude Code's official [status line](https://code.claude.com/docs/en/statusline) feature, so it survives updates. The wrapper is **non-destructive and scope-aware**: any status line you already had is composed above the ContextSpin line, and in a project (`CLAUDE_PROJECT_DIR` set) it writes the gitignored `<project>/.claude/settings.local.json` so a repo's own status line can't shadow it. Reverse with `uninject` (this scope) or `uninstall` (everything).
301
150
 
302
- ```json
303
- { "type": "mcp", "tool": "notion-search", "args": { "query": "assigned:me status:open" },
304
- "format": "Notion: {{ text }}", "label": "Notion", "cooldown": 300, "maxSnippets": 2 }
305
- ```
306
-
307
- ### Tier 3 — work queue (your to-do)
308
-
309
- ```json
310
- { "type": "cli", "command": "gh pr list --review-requested @me --json title,number --limit 3",
311
- "format": "PR #{{ number }} needs your review: {{ title }}", "label": "GitHub", "cooldown": 120, "maxSnippets": 3 }
312
- ```
151
+ There's also an **experimental** `patcher` mode (`injection.mode: "patcher"`) that rewrites Claude Code's hard-coded spinner words in the binary — inspired by [claude-depester](https://github.com/ominiverdi/claude-depester). It's length-preserving and best-effort, but **every Claude Code update overwrites it**, so the statusline is the supported path. Restore with `uninject --mode patcher`.
313
152
 
314
153
  ## Limitations
315
154
 
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.
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.
318
- - **The status line shows one rotating snippet** at a time, honoring `cooldownAfterShown` so the same item doesn't repeat indefinitely.
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.
320
-
321
- ## Zero-config defaults (never an empty bar)
322
-
323
- A fresh install needs no setup:
324
-
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.
155
+ - **MCP is stdio-only** discovered from `~/.claude.json` / `.mcp.json`; HTTP/SSE MCP transports aren't supported (use a `cli`/`http` source instead).
156
+ - **OAuth claude.ai connectors aren't reachable** their tokens live in the OS keychain, out of reach of a standalone process. Use the matching CLI (`gh`, …), an HTTP endpoint, or a local stdio MCP server.
328
157
 
329
- ## References
158
+ ## Also available as a plugin
330
159
 
331
- - claude-depester (patcher inspiration): https://github.com/ominiverdi/claude-depester
332
- - Claude Code status line docs: https://code.claude.com/docs/en/statusline
333
- - Claude Code spinner issues: [#10420](https://github.com/anthropics/claude-code/issues/10420), [#13725](https://github.com/anthropics/claude-code/issues/13725), [#22668](https://github.com/anthropics/claude-code/issues/22668), [#27766](https://github.com/anthropics/claude-code/issues/27766), [#27976](https://github.com/anthropics/claude-code/issues/27976)
160
+ A Claude Code plugin wraps this package, via the [`mannutech` marketplace](https://github.com/mannutech/claude-plugins) — for those who prefer installing that way. The curl line above needs neither.
334
161
 
335
162
  ## License
336
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
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
@@ -13,6 +13,7 @@ import {
13
13
  CONFIG_PATH,
14
14
  STATUSLINE_SH,
15
15
  CLAUDE_SETTINGS_PATH,
16
+ DEFAULT_DAEMONLESS,
16
17
  configExists,
17
18
  loadConfig,
18
19
  saveConfig,
@@ -24,6 +25,7 @@ import {
24
25
  stopDaemon,
25
26
  isDaemonRunning,
26
27
  readCache,
28
+ runRefreshOnce,
27
29
  } from './daemon.js';
28
30
  import {
29
31
  installStatusline,
@@ -264,7 +266,20 @@ async function runEnsure() {
264
266
  if (!wasOurs) did.push('wired statusline');
265
267
  }
266
268
 
267
- if (!isDaemonRunning().running) {
269
+ // DAEMONLESS (default): the render revalidates itself, so no background
270
+ // process is started. Only the legacy daemon engine needs a daemon.
271
+ const daemonless =
272
+ config && config.injection && typeof config.injection.daemonless === 'boolean'
273
+ ? config.injection.daemonless
274
+ : DEFAULT_DAEMONLESS;
275
+ if (daemonless) {
276
+ // Upgrading from the daemon engine? Stop the now-redundant background
277
+ // process so it doesn't linger after the switch.
278
+ if (isDaemonRunning().running) {
279
+ await stopDaemon();
280
+ did.push('stopped legacy daemon');
281
+ }
282
+ } else if (!isDaemonRunning().running) {
268
283
  startDaemonDetached();
269
284
  did.push('started daemon');
270
285
  }
@@ -326,16 +341,45 @@ async function runRestart() {
326
341
  await runStart();
327
342
  }
328
343
 
344
+ /**
345
+ * Force a one-shot refresh now (the daemonless "refresh now"): polls every due
346
+ * source and rewrites the cache. Works regardless of engine.
347
+ * @returns {Promise<void>}
348
+ */
349
+ async function runRefresh() {
350
+ if (!configExists()) {
351
+ printSetupHint();
352
+ process.exit(1);
353
+ return;
354
+ }
355
+ await runRefreshOnce();
356
+ console.log('ContextSpin: refreshed.');
357
+ }
358
+
329
359
  /**
330
360
  * Print the daemon running state plus the current cache contents.
331
361
  * @returns {Promise<void>}
332
362
  */
333
363
  async function runStatus() {
364
+ // Report the active engine. In daemonless mode (the default) there is no
365
+ // background process by design — the render revalidates the cache itself.
366
+ let daemonless = DEFAULT_DAEMONLESS;
367
+ try {
368
+ const cfg = await loadConfig();
369
+ if (cfg && cfg.injection && typeof cfg.injection.daemonless === 'boolean') {
370
+ daemonless = cfg.injection.daemonless;
371
+ }
372
+ } catch {
373
+ // no/invalid config -> assume the default engine
374
+ }
375
+
334
376
  const { running, pid } = isDaemonRunning();
335
- if (running) {
336
- console.log(`Daemon: running (pid ${pid})`);
377
+ if (daemonless) {
378
+ console.log('Engine: daemonless (statusline self-refreshes; no background process)');
379
+ } else if (running) {
380
+ console.log(`Engine: daemon (running, pid ${pid})`);
337
381
  } else {
338
- console.log('Daemon: stopped');
382
+ console.log('Engine: daemon (stopped)');
339
383
  }
340
384
 
341
385
  const cache = await readCache();
@@ -590,9 +634,14 @@ function buildProgram() {
590
634
  .description('Restart the background daemon')
591
635
  .action(action(async () => runRestart()));
592
636
 
637
+ program
638
+ .command('refresh')
639
+ .description('Force a one-shot refresh of all due sources now')
640
+ .action(action(async () => runRefresh()));
641
+
593
642
  program
594
643
  .command('status')
595
- .description('Show daemon state and cached snippets')
644
+ .description('Show the engine and cached snippets')
596
645
  .action(action(async () => runStatus()));
597
646
 
598
647
  program
package/src/config.js CHANGED
@@ -31,6 +31,17 @@ export const PID_PATH = path.join(STATE_DIR, "daemon.pid");
31
31
  /** Path to the daemon log file. */
32
32
  export const LOG_PATH = path.join(STATE_DIR, "daemon.log");
33
33
 
34
+ /**
35
+ * Lock file for the DAEMONLESS engine: the render script triggers a detached
36
+ * one-shot refresh when a source is due, guarded by this lock so frequent
37
+ * renders never spawn overlapping refreshes. Holds a timestamp; stale locks
38
+ * (older than REFRESH_LOCK_TTL_MS) are ignored.
39
+ */
40
+ export const REFRESH_LOCK_PATH = path.join(STATE_DIR, "refresh.lock");
41
+
42
+ /** A refresh lock older than this (ms) is considered stale and overridable. */
43
+ export const REFRESH_LOCK_TTL_MS = 60_000;
44
+
34
45
  /** Path to the generated statusline bash wrapper. */
35
46
  export const STATUSLINE_SH = path.join(STATE_DIR, "statusline.sh");
36
47
 
@@ -137,6 +148,15 @@ export const DEFAULTS = {
137
148
  snippets: { deduplication: true, cooldownAfterShown: 3, priorityOrder: [] },
138
149
  };
139
150
 
151
+ /**
152
+ * Whether the DAEMONLESS engine is the default. When true, no background daemon
153
+ * runs: the statusline render does stale-while-revalidate — it serves the cached
154
+ * snippet instantly and triggers a detached one-shot refresh when a source is
155
+ * due. Idle cost is then zero (nothing runs unless the bar is being drawn).
156
+ * Honored unless a config explicitly sets `injection.daemonless`.
157
+ */
158
+ export const DEFAULT_DAEMONLESS = true;
159
+
140
160
  /** Per-source defaults applied when a field is omitted. */
141
161
  export const SOURCE_DEFAULTS = { cooldown: 300, maxSnippets: 2 };
142
162
 
package/src/daemon.js CHANGED
@@ -25,13 +25,16 @@ export async function readCache() {
25
25
  try {
26
26
  const raw = await fsp.readFile(CACHE_PATH, "utf8");
27
27
  const parsed = JSON.parse(raw);
28
- if (!parsed || typeof parsed !== "object") return { updatedAt: null, snippets: [] };
28
+ if (!parsed || typeof parsed !== "object") return { updatedAt: null, snippets: [], meta: {} };
29
29
  return {
30
30
  updatedAt: parsed.updatedAt ?? null,
31
31
  snippets: Array.isArray(parsed.snippets) ? parsed.snippets : [],
32
+ // `meta.lastRun` maps sourceId -> last poll epoch ms, so the daemonless
33
+ // one-shot refresh can honor per-source cooldowns across separate runs.
34
+ meta: parsed.meta && typeof parsed.meta === "object" ? parsed.meta : {},
32
35
  };
33
36
  } catch {
34
- return { updatedAt: null, snippets: [] };
37
+ return { updatedAt: null, snippets: [], meta: {} };
35
38
  }
36
39
  }
37
40
 
@@ -159,6 +162,53 @@ export async function pollOnce(config, runtime) {
159
162
  return runtime.snippets;
160
163
  }
161
164
 
165
+ /**
166
+ * Run ONE refresh pass for the daemonless engine and exit. Unlike the daemon
167
+ * loop this keeps no in-memory state: it reads the cache (snippets +
168
+ * meta.lastRun), polls only the sources whose cooldown has elapsed, keeps the
169
+ * existing snippets for sources that are not yet due, merges, and writes the
170
+ * cache back (including the updated per-source lastRun). Errors for one source
171
+ * never drop the others' cached snippets.
172
+ *
173
+ * @param {{configPath?: string}} [opts]
174
+ * @returns {Promise<void>}
175
+ */
176
+ export async function runRefreshOnce(opts = {}) {
177
+ const config = await loadConfig(opts.configPath);
178
+ const cache = await readCache();
179
+ const lastRun = cache.meta && typeof cache.meta.lastRun === "object" ? { ...cache.meta.lastRun } : {};
180
+ const now = Date.now();
181
+
182
+ // Group existing cached snippets by source so not-yet-due sources persist.
183
+ const bySource = {};
184
+ for (const s of cache.snippets) {
185
+ if (!s) continue;
186
+ (bySource[s.sourceId] ||= []).push(s);
187
+ }
188
+
189
+ for (const source of config.sources) {
190
+ const last = lastRun[source.id] || 0;
191
+ if (now - last >= source.cooldown * 1000) {
192
+ try {
193
+ bySource[source.id] = await runSource(source, {});
194
+ lastRun[source.id] = Date.now();
195
+ } catch (err) {
196
+ console.error(`source "${source.label}" (#${source.id}) failed: ${err.message}`);
197
+ // keep whatever was cached for this source
198
+ }
199
+ }
200
+ }
201
+
202
+ const flattened = [];
203
+ for (const source of config.sources) {
204
+ const bucket = bySource[source.id];
205
+ if (Array.isArray(bucket)) flattened.push(...bucket);
206
+ }
207
+
208
+ const snippets = mergeSnippets(cache.snippets, flattened, config);
209
+ await writeCache({ updatedAt: nowISO(), snippets, meta: { lastRun } });
210
+ }
211
+
162
212
  /**
163
213
  * Run the daemon poll loop. Writes the PID file, installs signal handlers, and
164
214
  * loops: pollOnce -> writeCache -> wait config.injection.refresh seconds.
@@ -34,7 +34,11 @@ import {
34
34
  CLAUDE_SETTINGS_PATH,
35
35
  DEFAULT_SNIPPETS,
36
36
  WIRED_STATUSLINES_PATH,
37
+ REFRESH_LOCK_PATH,
38
+ REFRESH_LOCK_TTL_MS,
39
+ DEFAULT_DAEMONLESS,
37
40
  } from "../config.js";
41
+ import { fileURLToPath } from "node:url";
38
42
 
39
43
  /**
40
44
  * Build the source text of the Node ESM render script that Claude Code invokes
@@ -70,12 +74,16 @@ import {
70
74
  * @param {string} prevPath - Absolute path to the prev-statusline MAP JSON file.
71
75
  * @returns {string} The ESM source of the render script.
72
76
  */
73
- function buildRenderScript(cachePath, configPath, prevPath) {
77
+ function buildRenderScript(cachePath, configPath, prevPath, opts = {}) {
74
78
  const CACHE = JSON.stringify(cachePath);
75
79
  const CONFIG = JSON.stringify(configPath);
76
80
  const PREV = JSON.stringify(prevPath);
77
81
  const DEFAULTS = JSON.stringify(DEFAULT_SNIPPETS);
78
- return `// contextspin statusline-render.js (generated) composes any prior
82
+ const DAEMONLESS = opts.daemonless ? "true" : "false";
83
+ const REFRESH_ENTRY = JSON.stringify(opts.refreshEntry || "");
84
+ const LOCK = JSON.stringify(opts.lockPath || "");
85
+ const LOCK_TTL = String(typeof opts.lockTtlMs === "number" ? opts.lockTtlMs : 60000);
86
+ return `// contextspin statusline-render.mjs (generated) — composes any prior
79
87
  // statusline (looked up per-project) with one ContextSpin snippet line. MUST
80
88
  // always exit 0 and never lose the prior statusline's output, so the user's
81
89
  // status bar never breaks.
@@ -87,6 +95,14 @@ const CONFIG_PATH = ${CONFIG};
87
95
  const PREV_STATUSLINE_PATH = ${PREV};
88
96
  const DEFAULT_SNIPPETS = ${DEFAULTS};
89
97
 
98
+ // DAEMONLESS engine: when true, this render does stale-while-revalidate — it
99
+ // serves the cached snippet instantly and triggers a detached one-shot refresh
100
+ // when a source is due (lock-guarded so frequent renders never overlap).
101
+ const DAEMONLESS = ${DAEMONLESS};
102
+ const REFRESH_ENTRY = ${REFRESH_ENTRY};
103
+ const REFRESH_LOCK_PATH = ${LOCK};
104
+ const REFRESH_LOCK_TTL_MS = ${LOCK_TTL};
105
+
90
106
  /** Buffer ALL of stdin into a Buffer. Resolves on end/close/error/timeout. */
91
107
  function readStdin() {
92
108
  return new Promise((resolve) => {
@@ -341,6 +357,65 @@ function styleLine(text) {
341
357
  return BAR + "┃" + RESET + " " + BODY + text + RESET + " " + BAR + "┃" + RESET;
342
358
  }
343
359
 
360
+ /**
361
+ * DAEMONLESS stale-while-revalidate: if any source is past its cooldown and no
362
+ * fresh refresh is in flight, spawn a detached one-shot refresh. Never blocks
363
+ * the render (fire-and-forget) and never throws.
364
+ */
365
+ function maybeTriggerRefresh() {
366
+ if (!DAEMONLESS || !REFRESH_ENTRY) return;
367
+ try {
368
+ // Is any source due? (sourceId is the source's index in the config.)
369
+ let cfg;
370
+ try {
371
+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
372
+ } catch {
373
+ return;
374
+ }
375
+ const sources = Array.isArray(cfg && cfg.sources) ? cfg.sources : [];
376
+ if (sources.length === 0) return;
377
+
378
+ let lastRun = {};
379
+ try {
380
+ const c = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
381
+ if (c && c.meta && typeof c.meta.lastRun === "object") lastRun = c.meta.lastRun;
382
+ } catch {
383
+ // no cache yet -> everything is due
384
+ }
385
+
386
+ const now = Date.now();
387
+ let due = false;
388
+ for (let i = 0; i < sources.length; i++) {
389
+ const cd = (typeof sources[i].cooldown === "number" ? sources[i].cooldown : 300) * 1000;
390
+ if (now - (lastRun[i] || 0) >= cd) {
391
+ due = true;
392
+ break;
393
+ }
394
+ }
395
+ if (!due) return;
396
+
397
+ // Skip if a fresh refresh is already in flight.
398
+ try {
399
+ const t = Number(fs.readFileSync(REFRESH_LOCK_PATH, "utf8"));
400
+ if (Number.isFinite(t) && now - t < REFRESH_LOCK_TTL_MS) return;
401
+ } catch {
402
+ // no/!readable lock -> proceed
403
+ }
404
+
405
+ const child = spawn(process.execPath, [REFRESH_ENTRY], {
406
+ detached: true,
407
+ stdio: "ignore",
408
+ env: Object.assign({}, process.env, {
409
+ CONTEXTSPIN_CONFIG: CONFIG_PATH,
410
+ CONTEXTSPIN_CACHE: CACHE_PATH,
411
+ }),
412
+ });
413
+ child.unref();
414
+ } catch {
415
+ // never let revalidation break the render
416
+ }
417
+ }
418
+
344
419
  /** Write a string to stdout, awaiting the flush callback. */
345
420
  function writeOut(text) {
346
421
  return new Promise((resolve) => {
@@ -374,6 +449,14 @@ async function main() {
374
449
  line = "";
375
450
  }
376
451
 
452
+ // (b2) DAEMONLESS: kick off a background refresh if anything is due. Detached
453
+ // and non-blocking — the line above is served immediately from cache.
454
+ try {
455
+ maybeTriggerRefresh();
456
+ } catch {
457
+ // ignore
458
+ }
459
+
377
460
  // (c) Compose: prior output, then our line on its own line beneath. We only
378
461
  // insert a separating newline when there is prior output that does not
379
462
  // already end in one, so a lone ContextSpin line stays a single clean line.
@@ -601,7 +684,20 @@ export async function installStatusline(config, opts = {}) {
601
684
  await writePrevMap(map);
602
685
 
603
686
  // (3) Render script (knows the prev-statusline MAP path) + bash wrapper.
604
- const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH);
687
+ // Resolve whether the DAEMONLESS engine is active (config opt-out wins) and the
688
+ // absolute path to the one-shot refresh entry, so the render can revalidate
689
+ // itself with no background daemon.
690
+ const daemonless =
691
+ config && config.injection && typeof config.injection.daemonless === "boolean"
692
+ ? config.injection.daemonless
693
+ : DEFAULT_DAEMONLESS;
694
+ const refreshEntry = fileURLToPath(new URL("../refresh-entry.js", import.meta.url));
695
+ const renderSource = buildRenderScript(CACHE_PATH, CONFIG_PATH, PREV_STATUSLINE_PATH, {
696
+ daemonless,
697
+ refreshEntry,
698
+ lockPath: REFRESH_LOCK_PATH,
699
+ lockTtlMs: REFRESH_LOCK_TTL_MS,
700
+ });
605
701
  await fsp.writeFile(STATUSLINE_JS, renderSource);
606
702
 
607
703
  // Silence stderr so node warnings never reach the status bar.
@@ -0,0 +1,58 @@
1
+ // src/refresh-entry.js — detached one-shot refresh for the DAEMONLESS engine.
2
+ //
3
+ // The statusline render spawns this (fire-and-forget) when a source is due. It
4
+ // is lock-guarded so frequent renders can never spawn overlapping refreshes:
5
+ // it acquires REFRESH_LOCK_PATH atomically (overriding a stale lock), runs one
6
+ // refresh pass, and releases the lock. If the lock is held and fresh, it exits
7
+ // immediately without doing anything.
8
+
9
+ import fs from "node:fs";
10
+ import { REFRESH_LOCK_PATH, REFRESH_LOCK_TTL_MS } from "./config.js";
11
+ import { runRefreshOnce } from "./daemon.js";
12
+
13
+ /**
14
+ * Try to acquire the refresh lock. Uses an exclusive create ("wx"); if the lock
15
+ * exists but is older than the TTL it is treated as stale and overridden.
16
+ * @returns {boolean} true if acquired.
17
+ */
18
+ function acquireLock() {
19
+ try {
20
+ fs.writeFileSync(REFRESH_LOCK_PATH, String(Date.now()), { flag: "wx" });
21
+ return true;
22
+ } catch {
23
+ // Lock exists — override it only if stale.
24
+ try {
25
+ const age = Date.now() - Number(fs.readFileSync(REFRESH_LOCK_PATH, "utf8")) || 0;
26
+ if (age >= REFRESH_LOCK_TTL_MS) {
27
+ fs.writeFileSync(REFRESH_LOCK_PATH, String(Date.now()));
28
+ return true;
29
+ }
30
+ } catch {
31
+ // unreadable lock — leave it; another runner owns it
32
+ }
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Release the refresh lock (best-effort). */
38
+ function releaseLock() {
39
+ try {
40
+ fs.rmSync(REFRESH_LOCK_PATH, { force: true });
41
+ } catch {
42
+ // ignore
43
+ }
44
+ }
45
+
46
+ if (!acquireLock()) {
47
+ // A fresh refresh is already in flight; nothing to do.
48
+ process.exit(0);
49
+ }
50
+
51
+ runRefreshOnce({})
52
+ .catch((err) => {
53
+ console.error(`contextspin refresh failed: ${err && err.message ? err.message : err}`);
54
+ })
55
+ .finally(() => {
56
+ releaseLock();
57
+ process.exit(0);
58
+ });