contextspin 0.6.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.6.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": {
package/src/cli.js CHANGED
@@ -25,7 +25,11 @@ import {
25
25
  isDaemonRunning,
26
26
  readCache,
27
27
  } from './daemon.js';
28
- import { installStatusline, uninstallStatusline } from './inject/statusline.js';
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
 
@@ -580,13 +584,21 @@ async function runInstall() {
580
584
  */
581
585
  async function runUninstall() {
582
586
  const removedHook = removeSessionStartHook();
583
- await uninstallStatusline({});
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);
584
591
  await stopDaemon();
585
592
  console.log(
586
- removedHook
587
- ? 'ContextSpin uninstalled (hook removed, statusline restored, daemon stopped).'
588
- : 'ContextSpin hook not found; statusline restored and daemon stopped.',
593
+ `ContextSpin uninstalled: removed the statusline from ${removed.length} ` +
594
+ `scope${removed.length === 1 ? '' : 's'}, ` +
595
+ `${removedHook ? 'dropped the SessionStart hook, ' : ''}stopped the daemon.`,
589
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
+ }
590
602
  }
591
603
 
592
604
  /**
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 });
@@ -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
+ }