contextspin 0.6.3 → 0.6.4

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