contextspin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ {
2
+ "sources": [
3
+ {
4
+ "type": "mcp",
5
+ "tool": "slack_search_public",
6
+ "args": {
7
+ "query": "mentions:me is:unread"
8
+ },
9
+ "format": "Slack: {{ text }}",
10
+ "label": "Slack",
11
+ "cooldown": 300,
12
+ "maxSnippets": 2
13
+ },
14
+ {
15
+ "type": "cli",
16
+ "command": "gh pr list --review-requested @me --json title,number --limit 3",
17
+ "format": "PR #{{ number }} needs your review: {{ title }}",
18
+ "label": "GitHub",
19
+ "cooldown": 120,
20
+ "maxSnippets": 3
21
+ },
22
+ {
23
+ "type": "cli",
24
+ "command": "gh run list --json status,name,headBranch --limit 5",
25
+ "filter": "{{ status }} == failure",
26
+ "format": "CI failing: {{ name }} on {{ headBranch }}",
27
+ "label": "CI",
28
+ "cooldown": 60,
29
+ "maxSnippets": 2
30
+ },
31
+ {
32
+ "type": "mcp",
33
+ "tool": "notion-search",
34
+ "args": {
35
+ "query": "assigned:me status:open"
36
+ },
37
+ "format": "Notion: {{ text }}",
38
+ "label": "Notion",
39
+ "cooldown": 300,
40
+ "maxSnippets": 2
41
+ },
42
+ {
43
+ "type": "http",
44
+ "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
45
+ "headers": {
46
+ "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}"
47
+ },
48
+ "jq": ".results[0].value",
49
+ "format": "Grafana: {{ value }}",
50
+ "label": "Grafana",
51
+ "cooldown": 30,
52
+ "maxSnippets": 1
53
+ }
54
+ ],
55
+ "injection": {
56
+ "mode": "statusline",
57
+ "refresh": 30,
58
+ "maxVisible": 5
59
+ },
60
+ "snippets": {
61
+ "deduplication": true,
62
+ "cooldownAfterShown": 3,
63
+ "priorityOrder": [
64
+ "incident",
65
+ "ci",
66
+ "slack",
67
+ "calendar",
68
+ "github",
69
+ "jira"
70
+ ]
71
+ }
72
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ContextSpin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,318 @@
1
+ # ContextSpin
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.
4
+
5
+ ## Key principle: ContextSpin does NOT fetch data
6
+
7
+ 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**:
8
+
9
+ - **existing MCP servers** registered in your `~/.claude.json` / `.mcp.json`
10
+ - **CLI tools** already installed and authenticated on your machine (`gh`, `kubectl`, `aws`, your own scripts…)
11
+ - **HTTP endpoints** you can already reach (internal dashboards, status APIs)
12
+
13
+ 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).
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ ┌─────────────────────────────────────────────────────────┐
19
+ │ SOURCES (things you already have) │
20
+ │ │
21
+ │ mcp ──► stdio MCP servers from ~/.claude.json │
22
+ │ cli ──► shell commands (gh, kubectl, scripts...) │
23
+ │ http ──► HTTP/JSON endpoints you can reach │
24
+ └───────────────────────────┬─────────────────────────────┘
25
+ │ poll on per-source cooldown
26
+
27
+ ┌─────────────────────────────────────────────────────────┐
28
+ │ POLLING DAEMON (detached background process) │
29
+ │ • runs each source, applies filter + format │
30
+ │ • merges / dedups / prioritizes snippets │
31
+ │ • writes ~/.contextspin-cache.json (atomic) │
32
+ └───────────────────────────┬─────────────────────────────┘
33
+ │ read cache
34
+
35
+ ┌─────────────────────────────────────────────────────────┐
36
+ │ INJECTOR │
37
+ │ statusline ──► ~/.contextspin/statusline.sh │
38
+ │ patches ~/.claude/settings.json │
39
+ │ patcher ──► rewrites spinner words in the binary │
40
+ │ (EXPERIMENTAL) │
41
+ └───────────────────────────┬─────────────────────────────┘
42
+
43
+
44
+ Claude Code spinner / status bar shows one snippet
45
+ ```
46
+
47
+ 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.
48
+
49
+ ## Install / Quickstart
50
+
51
+ Requires Node.js >= 18 (ContextSpin uses the built-in global `fetch`).
52
+
53
+ ```bash
54
+ # 1. Create a config (interactive, or non-interactive with --yes)
55
+ npx contextspin setup
56
+
57
+ # 2. Start the background polling daemon
58
+ npx contextspin start
59
+
60
+ # 3. Wire the snippets into the Claude Code status bar
61
+ npx contextspin inject
62
+ ```
63
+
64
+ 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.
65
+
66
+ Check what's happening at any time:
67
+
68
+ ```bash
69
+ npx contextspin status
70
+ ```
71
+
72
+ ## Source types
73
+
74
+ 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.
75
+
76
+ ### `mcp` — call a tool on an existing MCP server
77
+
78
+ 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.
79
+
80
+ ```json
81
+ {
82
+ "type": "mcp",
83
+ "tool": "slack_search_public",
84
+ "args": { "query": "mentions:me is:unread" },
85
+ "format": "Slack: {{ text }}",
86
+ "label": "Slack",
87
+ "cooldown": 300,
88
+ "maxSnippets": 2
89
+ }
90
+ ```
91
+
92
+ - `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.
93
+ - `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.
94
+ - `args` is passed straight through as the tool-call arguments.
95
+
96
+ ### `cli` — run a shell command
97
+
98
+ ```json
99
+ {
100
+ "type": "cli",
101
+ "command": "gh pr list --review-requested @me --json title,number --limit 3",
102
+ "format": "PR #{{ number }} needs your review: {{ title }}",
103
+ "label": "GitHub",
104
+ "cooldown": 120,
105
+ "maxSnippets": 3
106
+ }
107
+ ```
108
+
109
+ The command runs through your shell. Output parsing is forgiving:
110
+
111
+ - a **JSON array** → each element becomes a record (objects kept as-is; primitives wrapped as `{ value, text }`)
112
+ - a **JSON object** → a single record
113
+ - a **JSON primitive** → `{ value, text }`
114
+ - **anything else** → split into non-empty lines, each becoming `{ text, line, value }`
115
+
116
+ A non-zero exit throws; a configurable timeout (default 15s) protects against hangs.
117
+
118
+ ### `http` — fetch a JSON (or text) endpoint
119
+
120
+ ```json
121
+ {
122
+ "type": "http",
123
+ "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
124
+ "headers": { "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}" },
125
+ "jq": ".results[0].value",
126
+ "format": "Grafana: {{ value }}",
127
+ "label": "Grafana",
128
+ "cooldown": 30,
129
+ "maxSnippets": 1
130
+ }
131
+ ```
132
+
133
+ - `url` and header values are interpolated, so you can inject secrets with `{{ env.X }}` instead of hard-coding them.
134
+ - `method` defaults to `GET`; a `body` object is JSON-stringified with the right content-type.
135
+ - `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.
136
+
137
+ ## Configuration
138
+
139
+ 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`).
140
+
141
+ ```json
142
+ {
143
+ "sources": [
144
+ { "type": "cli", "command": "gh pr list --json title --limit 3", "format": "PR: {{ title }}" }
145
+ ],
146
+ "injection": {
147
+ "mode": "statusline",
148
+ "refresh": 30,
149
+ "maxVisible": 5
150
+ },
151
+ "snippets": {
152
+ "deduplication": true,
153
+ "cooldownAfterShown": 3,
154
+ "priorityOrder": ["incident", "ci", "slack", "calendar", "github", "jira"]
155
+ }
156
+ }
157
+ ```
158
+
159
+ ### Field reference
160
+
161
+ | Field | Type | Format / values | Default | Meaning |
162
+ |-------|------|-----------------|---------|---------|
163
+ | `sources` | array | non-empty | — | List of sources to poll. Required. |
164
+ | `sources[].type` | string | `mcp` \| `cli` \| `http` | — | Source kind. Required. |
165
+ | `sources[].tool` | string | tool name or `mcp__server__tool` | — | Required for `mcp`. |
166
+ | `sources[].command` | string | shell command | — | Required for `cli`. |
167
+ | `sources[].url` | string | URL (templated) | — | Required for `http`. |
168
+ | `sources[].format` | string | `{{ field }}` template | — | One-line render template. Required. |
169
+ | `sources[].filter` | string | `LEFT OP RIGHT` | — | Optional. Keep a record only if it passes. See below. |
170
+ | `sources[].label` | string | free text | derived | Shown as the snippet source. Derived if omitted: mcp→tool name, cli→first command token, http→hostname. |
171
+ | `sources[].cooldown` | number | seconds | `300` | Minimum seconds between polls of this source. |
172
+ | `sources[].maxSnippets` | number | count | `2` | Max snippets kept from one poll of this source. |
173
+ | `injection.mode` | string | `statusline` \| `patcher` \| `both` | `statusline` | How snippets reach the UI. |
174
+ | `injection.refresh` | number | seconds | `30` | Daemon poll interval and status-line refresh interval (seconds). |
175
+ | `injection.maxVisible` | number | count | `5` | Global cap on snippets held in the cache. |
176
+ | `snippets.deduplication` | boolean | — | `true` | Drop snippets with duplicate text when merging. |
177
+ | `snippets.cooldownAfterShown` | number | count | `3` | A snippet stops being eligible once shown this many times. |
178
+ | `snippets.priorityOrder` | string[] | source labels | `[]` | Earlier labels sort first (case-insensitive); unlisted sort last. |
179
+
180
+ ### Filters
181
+
182
+ `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`.
183
+
184
+ ```json
185
+ { "filter": "{{ status }} == failure" }
186
+ ```
187
+
188
+ Only one comparison is supported — there is no `&&` / `||`.
189
+
190
+ ## Injection modes
191
+
192
+ ### `statusline` (recommended, official)
193
+
194
+ 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.
195
+
196
+ `contextspin inject` (mode `statusline`) will:
197
+
198
+ 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.
199
+ 2. Write `~/.contextspin/statusline.sh` — a `0755` bash wrapper that `exec`s the render script.
200
+ 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.
201
+
202
+ Reverse it with `contextspin uninject` (restores your previous status line if a backup exists).
203
+
204
+ ### `patcher` (EXPERIMENTAL — binary patching)
205
+
206
+ > ⚠️ **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.
207
+
208
+ Key facts:
209
+
210
+ - 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.
211
+ - 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.
212
+ - A **restart of Claude Code is required** for the patch to take effect.
213
+ - **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.
214
+ - On macOS it makes a best-effort `codesign` re-sign after patching.
215
+
216
+ 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.
217
+
218
+ ## Daemon and cache
219
+
220
+ `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:
221
+
222
+ 1. For each source whose `cooldown` has elapsed, runs it, applies the filter, formats records, and slices to `maxSnippets`.
223
+ 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`.
224
+ 3. Atomically writes the cache, then sleeps `injection.refresh` seconds.
225
+
226
+ `stop` / `restart` manage the process; `status` reports whether it's running and lists the current snippets.
227
+
228
+ ### Cache file format (`~/.contextspin-cache.json`)
229
+
230
+ ```json
231
+ {
232
+ "updatedAt": "2026-06-17T09:00:00.000Z",
233
+ "snippets": [
234
+ {
235
+ "text": "CI failing: build on main",
236
+ "source": "CI",
237
+ "sourceId": 2,
238
+ "fetchedAt": "2026-06-17T09:00:00.000Z",
239
+ "shownCount": 0
240
+ }
241
+ ]
242
+ }
243
+ ```
244
+
245
+ `shownCount` is incremented by the status-line renderer each time a snippet is displayed; once it reaches `cooldownAfterShown` the snippet is no longer shown.
246
+
247
+ ## CLI commands
248
+
249
+ | Command | What it does |
250
+ |---------|--------------|
251
+ | `contextspin setup [--yes]` | Create `~/.contextspin.json` (interactive, or from the bundled example with `--yes` / non-TTY). |
252
+ | `contextspin start` | Start the detached polling daemon. |
253
+ | `contextspin stop` | Stop the daemon. |
254
+ | `contextspin restart` | Stop then start. |
255
+ | `contextspin status` | Show daemon state and the current cached snippets (source, age, shown count). |
256
+ | `contextspin inject [--mode <m>]` | Install the injector. `<m>` overrides `injection.mode` (`statusline` / `patcher` / `both`). |
257
+ | `contextspin uninject [--mode <m>]` | Reverse the injector. |
258
+ | `contextspin` *(no subcommand)* | `setup` if unconfigured, otherwise `start` then `inject`. |
259
+
260
+ ## High-impact snippets
261
+
262
+ Three tiers, by how time-sensitive they are.
263
+
264
+ ### Tier 1 — time-sensitive (act in minutes)
265
+
266
+ ```json
267
+ { "type": "cli", "command": "gh run list --json status,name,headBranch --limit 5",
268
+ "filter": "{{ status }} == failure",
269
+ "format": "CI failing: {{ name }} on {{ headBranch }}", "label": "CI", "cooldown": 60, "maxSnippets": 2 }
270
+ ```
271
+
272
+ ```json
273
+ { "type": "http", "url": "https://grafana.example.com/api/datasources/proxy/1/query?q=incidents",
274
+ "headers": { "Authorization": "Bearer {{ env.GRAFANA_TOKEN }}" },
275
+ "jq": ".results[0].value", "format": "Grafana: {{ value }}", "label": "Grafana", "cooldown": 30, "maxSnippets": 1 }
276
+ ```
277
+
278
+ ### Tier 2 — ambient ops (good to know)
279
+
280
+ ```json
281
+ { "type": "mcp", "tool": "slack_search_public", "args": { "query": "mentions:me is:unread" },
282
+ "format": "Slack: {{ text }}", "label": "Slack", "cooldown": 300, "maxSnippets": 2 }
283
+ ```
284
+
285
+ ```json
286
+ { "type": "mcp", "tool": "notion-search", "args": { "query": "assigned:me status:open" },
287
+ "format": "Notion: {{ text }}", "label": "Notion", "cooldown": 300, "maxSnippets": 2 }
288
+ ```
289
+
290
+ ### Tier 3 — work queue (your to-do)
291
+
292
+ ```json
293
+ { "type": "cli", "command": "gh pr list --review-requested @me --json title,number --limit 3",
294
+ "format": "PR #{{ number }} needs your review: {{ title }}", "label": "GitHub", "cooldown": 120, "maxSnippets": 3 }
295
+ ```
296
+
297
+ ## Limitations
298
+
299
+ - **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.
300
+ - **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.
301
+ - **The status line shows one rotating snippet** at a time, honoring `cooldownAfterShown` so the same item doesn't repeat indefinitely.
302
+ - **The patcher is experimental** and is **overwritten by every Claude Code update**. Treat it as best-effort; the statusline mode is the supported path.
303
+
304
+ ## Roadmap
305
+
306
+ - **Stage 1 (now):** stdio MCP / CLI / HTTP sources, polling daemon + cache, statusline injection, experimental binary patcher, the CLI above.
307
+ - **Stage 2 (polish):** quality-of-life improvements — better source discovery, richer setup wizard, more diagnostics.
308
+ - **Stage 3 (`.plugin`):** package ContextSpin as a first-class Claude Code plugin.
309
+
310
+ ## References
311
+
312
+ - claude-depester (patcher inspiration): https://github.com/ominiverdi/claude-depester
313
+ - Claude Code status line docs: https://code.claude.com/docs/en/statusline
314
+ - 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)
315
+
316
+ ## License
317
+
318
+ MIT. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "contextspin",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "contextspin": "src/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test",
14
+ "start": "node src/cli.js",
15
+ "daemon": "node src/daemon-entry.js"
16
+ },
17
+ "dependencies": {
18
+ "commander": "^12.1.0"
19
+ },
20
+ "optionalDependencies": {
21
+ "node-lief": "^1.0.0"
22
+ },
23
+ "keywords": [
24
+ "claude-code",
25
+ "claude",
26
+ "statusline",
27
+ "spinner",
28
+ "mcp",
29
+ "devtools",
30
+ "cli",
31
+ "productivity"
32
+ ],
33
+ "license": "MIT",
34
+ "files": [
35
+ "src",
36
+ "README.md",
37
+ "LICENSE",
38
+ ".contextspin.example.json"
39
+ ]
40
+ }