contextspin 0.3.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextspin",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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/config.js CHANGED
@@ -56,6 +56,65 @@ export const CLAUDE_USER_CONFIG_PATH = path.join(HOME, ".claude.json");
56
56
  /** Suffix appended to a Claude install path to name its patcher backup. */
57
57
  export const PATCHER_BACKUP_SUFFIX = ".contextspin.backup";
58
58
 
59
+ /**
60
+ * Built-in "prefilled" snippet texts. The statusline render script falls back to
61
+ * these (rotating through them) whenever there is no live snippet to show — so
62
+ * the status bar is NEVER empty, even immediately after install before the daemon
63
+ * has fetched anything, or when every source returns nothing. They double as
64
+ * onboarding hints pointing at the next useful thing to configure.
65
+ */
66
+ export const DEFAULT_SNIPPETS = [
67
+ // Jokes — always available, even fully offline, so the bar is fun from second
68
+ // one. At least five so the rotation never feels repetitive.
69
+ "😄 Why do programmers prefer dark mode? Because light attracts bugs.",
70
+ "😄 A SQL query walks into a bar, sees two tables and asks: can I join you?",
71
+ "😄 Why did the developer go broke? He used up all his cache.",
72
+ "😄 There are 10 kinds of people: those who read binary and those who don't.",
73
+ "😄 I'd tell you a UDP joke, but you might not get it.",
74
+ "😄 To understand recursion, you must first understand recursion.",
75
+ // Live-context teasers + onboarding — what to try next.
76
+ "🌤️ Local weather appears here once the daemon warms up",
77
+ "📊 Run /contextspin-manage to see how many PRs you've closed to date",
78
+ "👀 Add a GitHub source to surface PRs awaiting your review",
79
+ "📅 Add a calendar source to see your next meeting here",
80
+ "🛠️ Run /contextspin-setup to wire up more live sources",
81
+ ];
82
+
83
+ /**
84
+ * No-credentials starter sources seeded into the DEFAULT config on first install
85
+ * so the user sees REAL live context (weather, a fresh joke, the top HN story)
86
+ * immediately — not just static tips. All are public HTTP endpoints needing no
87
+ * auth. Combined with detected sources (e.g. review requests) by defaultConfig.
88
+ */
89
+ export const STARTER_SOURCES = [
90
+ {
91
+ type: "http",
92
+ url: "https://wttr.in/?format=3",
93
+ format: "🌤️ {{text}}",
94
+ label: "weather",
95
+ cooldown: 1800,
96
+ maxSnippets: 1,
97
+ },
98
+ {
99
+ type: "http",
100
+ url: "https://icanhazdadjoke.com/",
101
+ headers: { Accept: "text/plain" },
102
+ format: "😄 {{text}}",
103
+ label: "joke",
104
+ cooldown: 1800,
105
+ maxSnippets: 1,
106
+ },
107
+ {
108
+ type: "http",
109
+ url: "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=1",
110
+ jq: ".hits[0].title",
111
+ format: "📰 HN: {{value}}",
112
+ label: "hackernews",
113
+ cooldown: 600,
114
+ maxSnippets: 1,
115
+ },
116
+ ];
117
+
59
118
  /** Default top-level config sections. */
60
119
  export const DEFAULTS = {
61
120
  injection: { mode: "statusline", refresh: 30, maxVisible: 5 },
@@ -144,8 +203,16 @@ export function normalizeConfig(raw) {
144
203
  * @returns {object} A default config: { sources, injection, snippets }.
145
204
  */
146
205
  export function defaultConfig(sources) {
206
+ const detected = Array.isArray(sources) ? sources : [];
207
+ // Seed the no-credentials starter sources so a brand-new install shows REAL
208
+ // live context right away. Skip any starter whose label a detected source
209
+ // already provides, so we never double up.
210
+ const have = new Set(detected.map((s) => s && s.label).filter(Boolean));
211
+ const starters = STARTER_SOURCES.filter((s) => !have.has(s.label)).map((s) => ({
212
+ ...s,
213
+ }));
147
214
  return {
148
- sources: Array.isArray(sources) ? sources : [],
215
+ sources: [...detected, ...starters],
149
216
  injection: { mode: "statusline", refresh: 30, maxVisible: 5 },
150
217
  snippets: {
151
218
  deduplication: true,
@@ -159,6 +226,9 @@ export function defaultConfig(sources) {
159
226
  "github",
160
227
  "gitlab",
161
228
  "jira",
229
+ "weather",
230
+ "joke",
231
+ "hackernews",
162
232
  ],
163
233
  },
164
234
  };
@@ -32,6 +32,7 @@ import {
32
32
  CACHE_PATH,
33
33
  CONFIG_PATH,
34
34
  CLAUDE_SETTINGS_PATH,
35
+ DEFAULT_SNIPPETS,
35
36
  } from "../config.js";
36
37
 
37
38
  /**
@@ -72,6 +73,7 @@ function buildRenderScript(cachePath, configPath, prevPath) {
72
73
  const CACHE = JSON.stringify(cachePath);
73
74
  const CONFIG = JSON.stringify(configPath);
74
75
  const PREV = JSON.stringify(prevPath);
76
+ const DEFAULTS = JSON.stringify(DEFAULT_SNIPPETS);
75
77
  return `// contextspin statusline-render.js (generated) — composes any prior
76
78
  // statusline (looked up per-project) with one ContextSpin snippet line. MUST
77
79
  // always exit 0 and never lose the prior statusline's output, so the user's
@@ -82,6 +84,7 @@ import { spawn } from "node:child_process";
82
84
  const CACHE_PATH = ${CACHE};
83
85
  const CONFIG_PATH = ${CONFIG};
84
86
  const PREV_STATUSLINE_PATH = ${PREV};
87
+ const DEFAULT_SNIPPETS = ${DEFAULTS};
85
88
 
86
89
  /** Buffer ALL of stdin into a Buffer. Resolves on end/close/error/timeout. */
87
90
  function readStdin() {
@@ -243,16 +246,41 @@ function writeJsonAtomic(filePath, data) {
243
246
  fs.renameSync(tmp, filePath);
244
247
  }
245
248
 
246
- /** Compute the ContextSpin snippet line (may be ""); bumps shownCount. */
249
+ /**
250
+ * Pick a rotating built-in default snippet text. NEVER exhausts (defaults are
251
+ * always available) — this is the guarantee that the status bar is never empty.
252
+ * Persists a rotating index back into the cache object (caller writes it).
253
+ */
254
+ function defaultLine(cache) {
255
+ if (!Array.isArray(DEFAULT_SNIPPETS) || DEFAULT_SNIPPETS.length === 0) return "";
256
+ const n = DEFAULT_SNIPPETS.length;
257
+ const idx = Number.isInteger(cache && cache._defaultIndex) ? cache._defaultIndex : 0;
258
+ const text = DEFAULT_SNIPPETS[((idx % n) + n) % n];
259
+ if (cache && typeof cache === "object") {
260
+ cache._defaultIndex = (idx + 1) % n;
261
+ try {
262
+ writeJsonAtomic(CACHE_PATH, cache);
263
+ } catch {
264
+ // best effort — still show the default this render
265
+ }
266
+ }
267
+ return String(text).replace(/\\r?\\n/g, " ");
268
+ }
269
+
270
+ /**
271
+ * Compute the ContextSpin snippet line. ALWAYS returns a non-empty string: a
272
+ * live snippet when one is eligible, otherwise a rotating built-in default so the
273
+ * status bar is never blank.
274
+ */
247
275
  function contextSpinLine() {
248
276
  let cache;
249
277
  try {
250
278
  cache = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
251
279
  } catch {
252
- return "";
280
+ cache = {};
253
281
  }
254
- const snippets = Array.isArray(cache && cache.snippets) ? cache.snippets : [];
255
- if (snippets.length === 0) return "";
282
+ if (!cache || typeof cache !== "object") cache = {};
283
+ const snippets = Array.isArray(cache.snippets) ? cache.snippets : [];
256
284
 
257
285
  // cooldownAfterShown from config (fallback 3).
258
286
  let cooldownAfterShown = 3;
@@ -267,7 +295,8 @@ function contextSpinLine() {
267
295
  const eligible = snippets.filter(
268
296
  (s) => s && typeof s.text === "string" && (s.shownCount || 0) < cooldownAfterShown
269
297
  );
270
- if (eligible.length === 0) return "";
298
+ // No live snippet to show -> fall back to a rotating built-in default.
299
+ if (eligible.length === 0) return defaultLine(cache);
271
300
 
272
301
  eligible.sort((a, b) => {
273
302
  const ca = a.shownCount || 0;
@@ -289,6 +318,28 @@ function contextSpinLine() {
289
318
  return String(chosen.text).replace(/\\r?\\n/g, " ");
290
319
  }
291
320
 
321
+ /**
322
+ * Wrap the ContextSpin line in a compact, "boxed" ANSI style — bright italic
323
+ * text between cyan bars — so it stands out from the prior statusline. Honors
324
+ * \`injection.style: false\` in the config to opt out (plain text). Any error
325
+ * falls back to the plain text.
326
+ */
327
+ function styleLine(text) {
328
+ if (!text) return text;
329
+ let enabled = true;
330
+ try {
331
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
332
+ if (cfg && cfg.injection && cfg.injection.style === false) enabled = false;
333
+ } catch {
334
+ // keep enabled
335
+ }
336
+ if (!enabled) return text;
337
+ const BAR = "\\x1b[36m"; // cyan
338
+ const BODY = "\\x1b[3;96m"; // italic + bright cyan
339
+ const RESET = "\\x1b[0m";
340
+ return BAR + "┃" + RESET + " " + BODY + text + RESET + " " + BAR + "┃" + RESET;
341
+ }
342
+
292
343
  /** Write a string to stdout, awaiting the flush callback. */
293
344
  function writeOut(text) {
294
345
  return new Promise((resolve) => {
@@ -314,10 +365,10 @@ async function main() {
314
365
  prevOut = "";
315
366
  }
316
367
 
317
- // (b) ContextSpin snippet line.
368
+ // (b) ContextSpin snippet line (always non-empty; styled).
318
369
  let line = "";
319
370
  try {
320
- line = contextSpinLine();
371
+ line = styleLine(contextSpinLine());
321
372
  } catch {
322
373
  line = "";
323
374
  }