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 +1 -1
- package/src/config.js +71 -1
- package/src/inject/statusline.js +58 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contextspin",
|
|
3
|
-
"version": "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:
|
|
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
|
};
|
package/src/inject/statusline.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
280
|
+
cache = {};
|
|
253
281
|
}
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
}
|