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 +26 -17
- package/package.json +1 -1
- package/src/cli.js +43 -13
- package/src/config.js +10 -0
- package/src/daemon.js +10 -1
- package/src/inject/statusline.js +67 -0
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# ContextSpin
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
│
|
|
39
|
-
│ patcher ──► rewrites spinner words
|
|
40
|
-
│ (EXPERIMENTAL) │
|
|
42
|
+
│ composed into ~/.claude/settings.json │
|
|
43
|
+
│ patcher ──► rewrites spinner words (EXPERIMENTAL) │
|
|
41
44
|
└───────────────────────────┬─────────────────────────────┘
|
|
42
45
|
│
|
|
43
46
|
▼
|
|
44
|
-
|
|
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 |
|
|
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.
|
|
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` (
|
|
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
|
|
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
|
|
267
|
-
| `contextspin
|
|
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
|
|
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
|
-
##
|
|
321
|
+
## Zero-config defaults (never an empty bar)
|
|
322
|
+
|
|
323
|
+
A fresh install needs no setup:
|
|
315
324
|
|
|
316
|
-
-
|
|
317
|
-
- **
|
|
318
|
-
- **
|
|
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.
|
|
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 {
|
|
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).
|
|
479
|
-
*
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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:
|
|
550
|
+
hooks: [{ type: 'command', command: desired, timeout: 15 }],
|
|
529
551
|
});
|
|
530
|
-
obj.hooks.SessionStart =
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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 });
|
package/src/inject/statusline.js
CHANGED
|
@@ -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
|
+
}
|