contextspin 0.1.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/src/cli.js ADDED
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env node
2
+ // src/cli.js — Commander-based command-line interface for ContextSpin.
3
+
4
+ import fs from 'node:fs';
5
+ import fsp from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import readline from 'node:readline/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { Command } from 'commander';
12
+
13
+ import {
14
+ CONFIG_PATH,
15
+ configExists,
16
+ loadConfig,
17
+ saveConfig,
18
+ normalizeConfig,
19
+ } from './config.js';
20
+ import {
21
+ startDaemonDetached,
22
+ stopDaemon,
23
+ isDaemonRunning,
24
+ readCache,
25
+ } from './daemon.js';
26
+ import { installStatusline, uninstallStatusline } from './inject/statusline.js';
27
+ import { installPatcher, restorePatcher } from './inject/patcher.js';
28
+
29
+ /** Absolute path to this module's directory. */
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
31
+ /** Absolute path to the package root (one level up from src/). */
32
+ const ROOT = path.resolve(HERE, '..');
33
+
34
+ /**
35
+ * Read the package version from package.json, resolved relative to this module
36
+ * (never hard-coded). Falls back to "0.1.0" if it cannot be read.
37
+ * @returns {string}
38
+ */
39
+ function readVersion() {
40
+ try {
41
+ const pkgPath = path.join(ROOT, 'package.json');
42
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
43
+ return pkg.version || '0.1.0';
44
+ } catch {
45
+ return '0.1.0';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Wrap an async command action so any thrown error prints a single clean line
51
+ * and exits with code 1.
52
+ * @param {(...args:any[])=>Promise<void>} fn
53
+ * @returns {(...args:any[])=>Promise<void>}
54
+ */
55
+ function action(fn) {
56
+ return async (...args) => {
57
+ try {
58
+ await fn(...args);
59
+ } catch (err) {
60
+ const message = err && err.message ? err.message : String(err);
61
+ console.error(`contextspin: ${message}`);
62
+ process.exit(1);
63
+ }
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Format a millisecond age into a short human string (e.g. "12s", "3m", "2h").
69
+ * @param {number} ms
70
+ * @returns {string}
71
+ */
72
+ function formatAge(ms) {
73
+ if (!Number.isFinite(ms) || ms < 0) return '?';
74
+ const s = Math.floor(ms / 1000);
75
+ if (s < 60) return `${s}s`;
76
+ const m = Math.floor(s / 60);
77
+ if (m < 60) return `${m}m`;
78
+ const h = Math.floor(m / 60);
79
+ if (h < 24) return `${h}h`;
80
+ const d = Math.floor(h / 24);
81
+ return `${d}d`;
82
+ }
83
+
84
+ /**
85
+ * Print the "next steps" hint shown when no config is present.
86
+ * @returns {void}
87
+ */
88
+ function printSetupHint() {
89
+ console.error('No ContextSpin config found.');
90
+ console.error('Run: contextspin setup');
91
+ }
92
+
93
+ /**
94
+ * Write the bundled example config to the destination path. Confirms before
95
+ * overwriting an existing file unless `force` is true.
96
+ * @param {string} dest
97
+ * @param {boolean} force
98
+ * @returns {Promise<boolean>} true if written, false if skipped.
99
+ */
100
+ async function writeExampleConfig(dest, force) {
101
+ const examplePath = path.join(ROOT, '.contextspin.example.json');
102
+ const raw = await fsp.readFile(examplePath, 'utf8');
103
+ if (fs.existsSync(dest) && !force) {
104
+ console.log(`Config already exists at ${dest} (left unchanged).`);
105
+ return false;
106
+ }
107
+ await fsp.writeFile(dest, raw);
108
+ console.log(`Wrote example config to ${dest}`);
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Run the setup command: create a config either non-interactively (example
114
+ * config) or via an interactive prompt that builds a minimal config.
115
+ * @param {{ yes?: boolean }} opts
116
+ * @returns {Promise<void>}
117
+ */
118
+ async function runSetup(opts = {}) {
119
+ const interactive = process.stdin.isTTY && !opts.yes;
120
+
121
+ if (!interactive) {
122
+ // Non-TTY or --yes: drop the example config unless one already exists.
123
+ if (configExists()) {
124
+ console.log(`Config already exists at ${CONFIG_PATH} (left unchanged).`);
125
+ } else {
126
+ await writeExampleConfig(CONFIG_PATH, false);
127
+ }
128
+ console.log('');
129
+ console.log('Next steps:');
130
+ console.log(' contextspin start # start the background daemon');
131
+ console.log(' contextspin inject # wire up your Claude Code status bar');
132
+ return;
133
+ }
134
+
135
+ const rl = readline.createInterface({
136
+ input: process.stdin,
137
+ output: process.stdout,
138
+ });
139
+ try {
140
+ if (configExists()) {
141
+ const ans = (
142
+ await rl.question(
143
+ `A config already exists at ${CONFIG_PATH}. Overwrite? (y/N) `,
144
+ )
145
+ )
146
+ .trim()
147
+ .toLowerCase();
148
+ if (ans !== 'y' && ans !== 'yes') {
149
+ console.log('Keeping the existing config. Nothing changed.');
150
+ return;
151
+ }
152
+ }
153
+
154
+ const modeRaw = (
155
+ await rl.question('Injection mode? statusline / patcher / both [statusline]: ')
156
+ )
157
+ .trim()
158
+ .toLowerCase();
159
+ const mode = ['statusline', 'patcher', 'both'].includes(modeRaw)
160
+ ? modeRaw
161
+ : 'statusline';
162
+
163
+ const refreshRaw = (
164
+ await rl.question('Refresh interval in seconds [30]: ')
165
+ ).trim();
166
+ const refreshParsed = Number.parseInt(refreshRaw, 10);
167
+ const refresh = Number.isFinite(refreshParsed) && refreshParsed > 0
168
+ ? refreshParsed
169
+ : 30;
170
+
171
+ /** @type {Array<object>} */
172
+ const sources = [];
173
+ const seedAns = (
174
+ await rl.question('Seed a couple of safe starter sources? (Y/n) ')
175
+ )
176
+ .trim()
177
+ .toLowerCase();
178
+ if (seedAns !== 'n' && seedAns !== 'no') {
179
+ // Safe starters: read-only `gh` queries that do nothing harmful.
180
+ sources.push({
181
+ type: 'cli',
182
+ command: 'gh pr list --review-requested @me --json title,number --limit 3',
183
+ format: 'PR #{{ number }} needs your review: {{ title }}',
184
+ label: 'GitHub',
185
+ cooldown: 120,
186
+ maxSnippets: 3,
187
+ });
188
+ sources.push({
189
+ type: 'cli',
190
+ command: 'gh run list --json status,name,headBranch --limit 5',
191
+ filter: '{{ status }} == failure',
192
+ format: 'CI failing: {{ name }} on {{ headBranch }}',
193
+ label: 'CI',
194
+ cooldown: 60,
195
+ maxSnippets: 2,
196
+ });
197
+ }
198
+
199
+ const config = normalizeConfig({
200
+ sources,
201
+ injection: { mode, refresh },
202
+ });
203
+ await saveConfig(config, CONFIG_PATH);
204
+ console.log(`Saved config to ${CONFIG_PATH}`);
205
+ console.log('');
206
+ console.log('Next steps:');
207
+ console.log(' contextspin start # start the background daemon');
208
+ console.log(' contextspin inject # wire up your Claude Code status bar');
209
+ } finally {
210
+ rl.close();
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Start the background daemon. Requires a valid config.
216
+ * @returns {Promise<void>}
217
+ */
218
+ async function runStart() {
219
+ if (!configExists()) {
220
+ printSetupHint();
221
+ process.exit(1);
222
+ return;
223
+ }
224
+ // loadConfig validates; surfaces a clean error if the config is broken.
225
+ await loadConfig();
226
+ const res = await startDaemonDetached();
227
+ if (res.already) {
228
+ console.log(`ContextSpin daemon already running (pid ${res.pid}).`);
229
+ } else {
230
+ console.log(`ContextSpin daemon started (pid ${res.pid}).`);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Stop the background daemon.
236
+ * @returns {Promise<void>}
237
+ */
238
+ async function runStop() {
239
+ const res = await stopDaemon();
240
+ if (res.stopped) {
241
+ console.log(`ContextSpin daemon stopped (pid ${res.pid}).`);
242
+ } else {
243
+ console.log('ContextSpin daemon was not running.');
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Restart the background daemon: stop then start.
249
+ * @returns {Promise<void>}
250
+ */
251
+ async function runRestart() {
252
+ await runStop();
253
+ await runStart();
254
+ }
255
+
256
+ /**
257
+ * Print the daemon running state plus the current cache contents.
258
+ * @returns {Promise<void>}
259
+ */
260
+ async function runStatus() {
261
+ const { running, pid } = isDaemonRunning();
262
+ if (running) {
263
+ console.log(`Daemon: running (pid ${pid})`);
264
+ } else {
265
+ console.log('Daemon: stopped');
266
+ }
267
+
268
+ const cache = await readCache();
269
+ const snippets = Array.isArray(cache.snippets) ? cache.snippets : [];
270
+ if (cache.updatedAt) {
271
+ console.log(`Cache updated: ${cache.updatedAt}`);
272
+ }
273
+
274
+ if (snippets.length === 0) {
275
+ console.log('No snippets cached yet.');
276
+ if (!running) {
277
+ console.log('Hint: run `contextspin start` to begin collecting context.');
278
+ }
279
+ return;
280
+ }
281
+
282
+ const now = Date.now();
283
+ console.log('');
284
+ console.log('Snippets:');
285
+ for (const snip of snippets) {
286
+ const fetched = Date.parse(snip.fetchedAt);
287
+ const age = Number.isFinite(fetched) ? formatAge(now - fetched) : '?';
288
+ const src = snip.source || `#${snip.sourceId}`;
289
+ const shown = Number.isFinite(snip.shownCount) ? snip.shownCount : 0;
290
+ console.log(` [${src}] ${snip.text} (age ${age}, shown ${shown})`);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Resolve the injection mode from a CLI option or the config default.
296
+ * @param {string|undefined} optionMode
297
+ * @param {object} config
298
+ * @returns {string}
299
+ */
300
+ function resolveMode(optionMode, config) {
301
+ if (optionMode) return optionMode;
302
+ return config && config.injection && config.injection.mode
303
+ ? config.injection.mode
304
+ : 'statusline';
305
+ }
306
+
307
+ /**
308
+ * Run the inject command for the chosen mode (statusline / patcher / both).
309
+ * @param {{ mode?: string }} opts
310
+ * @returns {Promise<void>}
311
+ */
312
+ async function runInject(opts = {}) {
313
+ const config = await loadConfig();
314
+ const mode = resolveMode(opts.mode, config);
315
+ if (!['statusline', 'patcher', 'both'].includes(mode)) {
316
+ throw new Error(
317
+ `unknown injection mode "${mode}" (expected statusline, patcher, or both)`,
318
+ );
319
+ }
320
+
321
+ if (mode === 'statusline' || mode === 'both') {
322
+ const res = await installStatusline(config);
323
+ console.log('Statusline installed:');
324
+ console.log(` script: ${res.statuslineSh}`);
325
+ console.log(` renderer: ${res.statuslineJs}`);
326
+ console.log(` settings: ${res.settingsPath}`);
327
+ if (res.backedUp) {
328
+ console.log(' (backed up your previous statusLine setting)');
329
+ }
330
+ if (res.warning) {
331
+ console.log(` warning: ${res.warning}`);
332
+ }
333
+ }
334
+
335
+ if (mode === 'patcher' || mode === 'both') {
336
+ const res = await installPatcher(config);
337
+ if (res.warning) {
338
+ console.log(`Patcher: ${res.warning}`);
339
+ }
340
+ const patched = Array.isArray(res.patched) ? res.patched : [];
341
+ if (patched.length > 0) {
342
+ console.log('Patcher applied to:');
343
+ for (const p of patched) {
344
+ const status = p.patched ? 'patched' : 'skipped';
345
+ const note = p.note ? ` — ${p.note}` : '';
346
+ console.log(` [${status}] ${p.path}${note}`);
347
+ }
348
+ }
349
+ if (res.wrapper) {
350
+ console.log(`Wrapper script: ${res.wrapper}`);
351
+ }
352
+ if (res.note) {
353
+ console.log(res.note);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Run the uninject command, reversing whichever injection mode is selected.
360
+ * @param {{ mode?: string }} opts
361
+ * @returns {Promise<void>}
362
+ */
363
+ async function runUninject(opts = {}) {
364
+ const config = await loadConfig();
365
+ const mode = resolveMode(opts.mode, config);
366
+ if (!['statusline', 'patcher', 'both'].includes(mode)) {
367
+ throw new Error(
368
+ `unknown injection mode "${mode}" (expected statusline, patcher, or both)`,
369
+ );
370
+ }
371
+
372
+ if (mode === 'statusline' || mode === 'both') {
373
+ const res = await uninstallStatusline();
374
+ if (res.removed) {
375
+ console.log(
376
+ res.restored
377
+ ? 'Statusline removed (restored your previous settings).'
378
+ : 'Statusline removed.',
379
+ );
380
+ } else {
381
+ console.log('Statusline: nothing to remove.');
382
+ }
383
+ if (res.note) console.log(` ${res.note}`);
384
+ }
385
+
386
+ if (mode === 'patcher' || mode === 'both') {
387
+ const res = await restorePatcher();
388
+ const restored = Array.isArray(res.restored) ? res.restored : [];
389
+ if (restored.length > 0) {
390
+ console.log('Patcher restore:');
391
+ for (const r of restored) {
392
+ const status = r.restored ? 'restored' : 'failed';
393
+ const note = r.note ? ` — ${r.note}` : '';
394
+ console.log(` [${status}] ${r.path}${note}`);
395
+ }
396
+ } else {
397
+ console.log('Patcher: no patched installs with backups found.');
398
+ }
399
+ }
400
+ }
401
+
402
+ /**
403
+ * The default action when no subcommand is given: set up if there is no config,
404
+ * otherwise start the daemon and inject per the configured mode.
405
+ * @returns {Promise<void>}
406
+ */
407
+ async function runDefault() {
408
+ if (!configExists()) {
409
+ await runSetup({});
410
+ return;
411
+ }
412
+ await runStart();
413
+ const config = await loadConfig();
414
+ await runInject({ mode: config.injection.mode });
415
+ }
416
+
417
+ /**
418
+ * Build and configure the Commander program.
419
+ * @returns {Command}
420
+ */
421
+ function buildProgram() {
422
+ const program = new Command();
423
+
424
+ program
425
+ .name('contextspin')
426
+ .description(
427
+ 'Replace your Claude Code spinner/statusline with live org context.',
428
+ )
429
+ .version(readVersion())
430
+ .showHelpAfterError();
431
+
432
+ program
433
+ .command('setup')
434
+ .description('Create a ContextSpin config (interactive, or --yes for example)')
435
+ .option('--yes', 'skip prompts and write the example config')
436
+ .action(action(async (opts) => runSetup(opts)));
437
+
438
+ program
439
+ .command('start')
440
+ .description('Start the background daemon')
441
+ .action(action(async () => runStart()));
442
+
443
+ program
444
+ .command('stop')
445
+ .description('Stop the background daemon')
446
+ .action(action(async () => runStop()));
447
+
448
+ program
449
+ .command('restart')
450
+ .description('Restart the background daemon')
451
+ .action(action(async () => runRestart()));
452
+
453
+ program
454
+ .command('status')
455
+ .description('Show daemon state and cached snippets')
456
+ .action(action(async () => runStatus()));
457
+
458
+ program
459
+ .command('inject')
460
+ .description('Wire ContextSpin into Claude Code (statusline/patcher/both)')
461
+ .option('--mode <m>', 'injection mode: statusline, patcher, or both')
462
+ .action(action(async (opts) => runInject(opts)));
463
+
464
+ program
465
+ .command('uninject')
466
+ .description('Remove ContextSpin from Claude Code')
467
+ .option('--mode <m>', 'injection mode: statusline, patcher, or both')
468
+ .action(action(async (opts) => runUninject(opts)));
469
+
470
+ // Default action: run when no subcommand is provided. Any leftover operand
471
+ // means the user typed an unrecognized command (e.g. a typo) — error on it
472
+ // rather than silently running the (potentially destructive) default.
473
+ program.action(
474
+ action(async (_opts, command) => {
475
+ const operands = (command && command.args) || [];
476
+ if (operands.length > 0) {
477
+ command.error(`unknown command '${operands[0]}'`);
478
+ return;
479
+ }
480
+ await runDefault();
481
+ }),
482
+ );
483
+
484
+ return program;
485
+ }
486
+
487
+ const program = buildProgram();
488
+ program.parseAsync(process.argv).catch((err) => {
489
+ const message = err && err.message ? err.message : String(err);
490
+ console.error(`contextspin: ${message}`);
491
+ process.exit(1);
492
+ });