contextspin 0.5.1 → 0.6.2
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 +17 -7
- package/install.sh +25 -0
- package/package.json +2 -1
- package/src/cli.js +142 -1
- package/src/config.js +10 -0
- package/src/daemon.js +10 -1
- package/src/inject/statusline.js +67 -0
package/README.md
CHANGED
|
@@ -46,23 +46,33 @@ ContextSpin polls those sources on a schedule, formats whatever they return into
|
|
|
46
46
|
|
|
47
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
48
|
|
|
49
|
-
## Install
|
|
49
|
+
## Install
|
|
50
50
|
|
|
51
51
|
Requires Node.js >= 18 (ContextSpin uses the built-in global `fetch`).
|
|
52
52
|
|
|
53
|
+
**One line — that's it:**
|
|
54
|
+
|
|
53
55
|
```bash
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
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.
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
npx contextspin start
|
|
61
|
+
Prefer to do it yourself? `npx contextspin install` does the same thing. To remove everything: `npx contextspin uninstall`.
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
<details>
|
|
64
|
+
<summary>Manual / advanced setup</summary>
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx contextspin setup # create a config (add --yes to skip prompts)
|
|
68
|
+
npx contextspin start # start the background polling daemon
|
|
69
|
+
npx contextspin inject # wire snippets into the status bar
|
|
62
70
|
```
|
|
63
71
|
|
|
64
72
|
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
73
|
|
|
74
|
+
</details>
|
|
75
|
+
|
|
66
76
|
Check what's happening at any time:
|
|
67
77
|
|
|
68
78
|
```bash
|
package/install.sh
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ContextSpin one-line installer.
|
|
3
|
+
#
|
|
4
|
+
# curl -fsSL https://raw.githubusercontent.com/mannutech/contextspin/main/install.sh | bash
|
|
5
|
+
#
|
|
6
|
+
# Wires a SessionStart hook into ~/.claude/settings.json (so ContextSpin
|
|
7
|
+
# self-heals every session) and sets up the config, statusline, and daemon.
|
|
8
|
+
# Non-destructive: any existing statusline is composed, not replaced.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
12
|
+
echo "ContextSpin needs Node.js 18+ — install it from https://nodejs.org and re-run." >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
|
17
|
+
if [ "${MAJOR}" -lt 18 ]; then
|
|
18
|
+
echo "ContextSpin needs Node.js 18+, but found $(node -v). Please upgrade." >&2
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
echo "Installing ContextSpin…"
|
|
23
|
+
# Run from a neutral dir so npx never resolves a confused local package.
|
|
24
|
+
cd /tmp
|
|
25
|
+
npx --yes contextspin@latest install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contextspin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
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": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"src",
|
|
36
36
|
"README.md",
|
|
37
37
|
"LICENSE",
|
|
38
|
+
"install.sh",
|
|
38
39
|
".contextspin.example.json"
|
|
39
40
|
]
|
|
40
41
|
}
|
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
|
|
|
@@ -472,6 +476,131 @@ async function runUninject(opts = {}) {
|
|
|
472
476
|
}
|
|
473
477
|
}
|
|
474
478
|
|
|
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
|
+
/**
|
|
562
|
+
* One-shot install (the `curl | bash` entrypoint): wire the SessionStart hook so
|
|
563
|
+
* ContextSpin self-heals each session, then run ensure (config + statusline +
|
|
564
|
+
* daemon) so it's live immediately. Prints a friendly summary.
|
|
565
|
+
* @returns {Promise<void>}
|
|
566
|
+
*/
|
|
567
|
+
async function runInstall() {
|
|
568
|
+
const addedHook = addSessionStartHook();
|
|
569
|
+
await runEnsure();
|
|
570
|
+
console.log('');
|
|
571
|
+
console.log(
|
|
572
|
+
addedHook
|
|
573
|
+
? '✨ ContextSpin installed — it auto-refreshes every Claude Code session.'
|
|
574
|
+
: '✨ ContextSpin already installed (SessionStart hook present).',
|
|
575
|
+
);
|
|
576
|
+
console.log(' Restart Claude Code to see your statusline.');
|
|
577
|
+
console.log(' Check it anytime: contextspin status');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Full uninstall: remove the SessionStart hook, the statusline wiring, and stop
|
|
582
|
+
* the daemon.
|
|
583
|
+
* @returns {Promise<void>}
|
|
584
|
+
*/
|
|
585
|
+
async function runUninstall() {
|
|
586
|
+
const removedHook = removeSessionStartHook();
|
|
587
|
+
// Tear down EVERY scope we wired (user + each project the hook touched), not
|
|
588
|
+
// just the user scope — otherwise project-scoped wirings keep rendering.
|
|
589
|
+
const results = await uninstallAllStatuslines();
|
|
590
|
+
const removed = results.filter((r) => r && r.removed);
|
|
591
|
+
await stopDaemon();
|
|
592
|
+
console.log(
|
|
593
|
+
`ContextSpin uninstalled: removed the statusline from ${removed.length} ` +
|
|
594
|
+
`scope${removed.length === 1 ? '' : 's'}, ` +
|
|
595
|
+
`${removedHook ? 'dropped the SessionStart hook, ' : ''}stopped the daemon.`,
|
|
596
|
+
);
|
|
597
|
+
const projectScopes = removed.filter((r) => r.scope === 'project');
|
|
598
|
+
if (projectScopes.length > 0) {
|
|
599
|
+
console.log('Cleaned project statuslines:');
|
|
600
|
+
for (const r of projectScopes) console.log(` ${r.settingsPath}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
475
604
|
/**
|
|
476
605
|
* The default action when no subcommand is given: set up if there is no config,
|
|
477
606
|
* otherwise start the daemon and inject per the configured mode.
|
|
@@ -508,6 +637,18 @@ function buildProgram() {
|
|
|
508
637
|
.option('--yes', 'skip prompts and write a detected config')
|
|
509
638
|
.action(action(async (opts) => runSetup(opts)));
|
|
510
639
|
|
|
640
|
+
program
|
|
641
|
+
.command('install')
|
|
642
|
+
.description(
|
|
643
|
+
'One-line install: wire the SessionStart hook so ContextSpin self-heals each session, then set up config + statusline + daemon',
|
|
644
|
+
)
|
|
645
|
+
.action(action(async () => runInstall()));
|
|
646
|
+
|
|
647
|
+
program
|
|
648
|
+
.command('uninstall')
|
|
649
|
+
.description('Remove ContextSpin entirely (SessionStart hook, statusline, daemon)')
|
|
650
|
+
.action(action(async () => runUninstall()));
|
|
651
|
+
|
|
511
652
|
program
|
|
512
653
|
.command('ensure')
|
|
513
654
|
.description(
|
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
|
+
}
|