claude-rpc 0.3.11 → 0.6.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/README.md +96 -136
- package/config.example.json +2 -65
- package/package.json +3 -2
- package/src/badge.js +38 -10
- package/src/card.js +345 -0
- package/src/cli.js +239 -12
- package/src/config.js +89 -0
- package/src/daemon.js +133 -23
- package/src/doctor.js +376 -0
- package/src/git.js +2 -2
- package/src/hook.js +1 -1
- package/src/install.js +51 -5
- package/src/pricing.js +29 -6
- package/src/privacy.js +231 -0
- package/src/scanner.js +62 -7
- package/src/server/api.js +175 -0
- package/src/server/index.js +98 -0
- package/src/{server.js → server/page.js} +58 -327
- package/src/server/routes.js +63 -0
- package/src/server/sse.js +32 -0
- package/src/tui.js +6 -7
- package/src/ui.js +89 -0
- package/src/version.js +26 -0
package/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import process from 'node:process';
|
|
|
8
8
|
// code page on Win10 is 437/850, which displays many of our chars as `?`.
|
|
9
9
|
// Hook events (no TTY) skip this — they don't print anything user-visible.
|
|
10
10
|
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
11
|
-
try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
11
|
+
try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
|
|
12
12
|
}
|
|
13
13
|
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, EXE_PATH, CANONICAL_EXE } from './paths.js';
|
|
14
14
|
import { readState } from './state.js';
|
|
@@ -20,6 +20,10 @@ import { startTui } from './tui.js';
|
|
|
20
20
|
import { generateInsights } from './insights.js';
|
|
21
21
|
import { badgeSvg } from './badge.js';
|
|
22
22
|
import { fmtCost } from './pricing.js';
|
|
23
|
+
import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
|
|
24
|
+
import { loadConfig, hasUserConfig } from './config.js';
|
|
25
|
+
import { VERSION } from './version.js';
|
|
26
|
+
import { fail, EX_USER_ERROR, EX_BAD_STATE } from './ui.js';
|
|
23
27
|
import { basename } from 'node:path';
|
|
24
28
|
|
|
25
29
|
const cmd = process.argv[2];
|
|
@@ -245,7 +249,7 @@ function bar(val, max, width = 22) {
|
|
|
245
249
|
function showStatus() {
|
|
246
250
|
const state = readState();
|
|
247
251
|
const aggregate = readAggregate();
|
|
248
|
-
const config =
|
|
252
|
+
const config = loadConfig();
|
|
249
253
|
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
250
254
|
state.liveSessions = live;
|
|
251
255
|
const vars = buildVars(state, config, aggregate);
|
|
@@ -429,7 +433,7 @@ function showStatus() {
|
|
|
429
433
|
function showToday() {
|
|
430
434
|
const state = readState();
|
|
431
435
|
const aggregate = readAggregate();
|
|
432
|
-
const config =
|
|
436
|
+
const config = loadConfig();
|
|
433
437
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
434
438
|
const vars = buildVars(state, config, aggregate);
|
|
435
439
|
|
|
@@ -457,7 +461,7 @@ function showToday() {
|
|
|
457
461
|
function showWeek() {
|
|
458
462
|
const state = readState();
|
|
459
463
|
const aggregate = readAggregate();
|
|
460
|
-
const config =
|
|
464
|
+
const config = loadConfig();
|
|
461
465
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
462
466
|
const vars = buildVars(state, config, aggregate);
|
|
463
467
|
|
|
@@ -517,7 +521,7 @@ function statusColor(status) {
|
|
|
517
521
|
function showPreview() {
|
|
518
522
|
let state = readState();
|
|
519
523
|
const aggregate = readAggregate();
|
|
520
|
-
const config =
|
|
524
|
+
const config = loadConfig();
|
|
521
525
|
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
522
526
|
state.liveSessions = live;
|
|
523
527
|
state = applyIdle(state, config);
|
|
@@ -557,7 +561,7 @@ function showPreview() {
|
|
|
557
561
|
// previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
|
|
558
562
|
function dumpVars() {
|
|
559
563
|
let state = readState();
|
|
560
|
-
const config =
|
|
564
|
+
const config = loadConfig();
|
|
561
565
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
562
566
|
state = applyIdle(state, config);
|
|
563
567
|
const live = buildVars(state, config, readAggregate() || {});
|
|
@@ -579,9 +583,53 @@ function doScan(force = false) {
|
|
|
579
583
|
});
|
|
580
584
|
process.stdout.write('\n');
|
|
581
585
|
console.log(`${c.green}✓${c.reset} Done in ${Date.now() - t0}ms — ${c.cyan}${result.scanned}${c.reset} parsed · ${result.skipped} cached · ${result.removed} removed (${result.total} total)`);
|
|
586
|
+
if (result.dirs && result.dirs.length > 1) {
|
|
587
|
+
console.log(`${c.dim}Scanned roots:${c.reset} ${result.dirs.join(', ')}`);
|
|
588
|
+
}
|
|
582
589
|
console.log(`${c.dim}Aggregate written to ${AGGREGATE_PATH}${c.reset}`);
|
|
583
590
|
}
|
|
584
591
|
|
|
592
|
+
// Backfill from any folder that has .jsonl transcripts. Useful for:
|
|
593
|
+
// • restoring from a backup of ~/.claude
|
|
594
|
+
// • merging transcripts from another machine
|
|
595
|
+
// • importing data from an older Claude Code install with a non-default path
|
|
596
|
+
//
|
|
597
|
+
// Walks the given path recursively, adds every .jsonl to the existing cache,
|
|
598
|
+
// and rebuilds the aggregate. Does NOT remove anything from the existing
|
|
599
|
+
// aggregate — adds only.
|
|
600
|
+
function doBackfill(argv) {
|
|
601
|
+
const path = argv[0];
|
|
602
|
+
if (!path) {
|
|
603
|
+
fail('usage: claude-rpc backfill <path>',
|
|
604
|
+
{ hint: 'point at any folder containing .jsonl transcripts (e.g. a backup of ~/.claude/projects)' });
|
|
605
|
+
}
|
|
606
|
+
if (!existsSync(path)) {
|
|
607
|
+
fail(`path doesn't exist: ${path}`,
|
|
608
|
+
{ hint: 'check the spelling, or run `claude-rpc doctor` to see where transcripts live' });
|
|
609
|
+
}
|
|
610
|
+
console.log(`${c.dim}Backfilling from${c.reset} ${c.cyan}${path}${c.reset}…`);
|
|
611
|
+
const t0 = Date.now();
|
|
612
|
+
let lastReport = 0;
|
|
613
|
+
// Pass `extraDirs` rather than `projectsDirs` — this way the default
|
|
614
|
+
// ~/.claude/projects (+ any auto-discovered alt paths) ALSO gets scanned
|
|
615
|
+
// and the user's existing cache for those isn't pruned.
|
|
616
|
+
const result = scan({
|
|
617
|
+
force: false,
|
|
618
|
+
extraDirs: [path],
|
|
619
|
+
onProgress: ({ scanned, total }) => {
|
|
620
|
+
if (Date.now() - lastReport > 500) {
|
|
621
|
+
process.stdout.write(`\r parsed ${scanned}/${total}…`);
|
|
622
|
+
lastReport = Date.now();
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
process.stdout.write('\n');
|
|
627
|
+
console.log(`${c.green}✓${c.reset} Done in ${Date.now() - t0}ms — ${c.cyan}${result.scanned}${c.reset} new/changed · ${result.skipped} cached`);
|
|
628
|
+
console.log(`${c.dim}Scanned roots:${c.reset} ${result.dirs.join(', ')}`);
|
|
629
|
+
const hours = ((result.aggregate.activeMs || 0) / 3_600_000).toFixed(1);
|
|
630
|
+
console.log(`${c.dim}Aggregate now:${c.reset} ${result.aggregate.sessions} sessions · ${hours}h · ${result.aggregate.userMessages} prompts`);
|
|
631
|
+
}
|
|
632
|
+
|
|
585
633
|
function showInsights() {
|
|
586
634
|
const aggregate = readAggregate();
|
|
587
635
|
const insights = generateInsights(aggregate, { limit: 6 });
|
|
@@ -608,8 +656,7 @@ function doBadge(argv) {
|
|
|
608
656
|
const opts = parseBadgeArgs(argv);
|
|
609
657
|
const aggregate = readAggregate();
|
|
610
658
|
if (!aggregate) {
|
|
611
|
-
|
|
612
|
-
process.exit(1);
|
|
659
|
+
fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
613
660
|
}
|
|
614
661
|
const svg = badgeSvg({ aggregate, metric: opts.metric, range: opts.range, label: opts.label });
|
|
615
662
|
if (opts.out) {
|
|
@@ -620,6 +667,84 @@ function doBadge(argv) {
|
|
|
620
667
|
}
|
|
621
668
|
}
|
|
622
669
|
|
|
670
|
+
// Poster-style SVG card. Bigger sibling of `badge` — shareable summary
|
|
671
|
+
// for a range (year / month / week / all-time). Output is SVG only;
|
|
672
|
+
// screenshot or convert to PNG offline if needed.
|
|
673
|
+
function parseCardArgs(argv) {
|
|
674
|
+
const out = { range: 'year', out: '' };
|
|
675
|
+
for (let i = 0; i < argv.length; i++) {
|
|
676
|
+
const a = argv[i];
|
|
677
|
+
if (a === '--range' || a === '-r') out.range = argv[++i];
|
|
678
|
+
else if (a === '--out' || a === '-o') out.out = argv[++i];
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function doCard(argv) {
|
|
684
|
+
const opts = parseCardArgs(argv);
|
|
685
|
+
const aggregate = readAggregate();
|
|
686
|
+
if (!aggregate) {
|
|
687
|
+
fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
688
|
+
}
|
|
689
|
+
const { renderCard } = await import('./card.js');
|
|
690
|
+
const svg = renderCard(aggregate, { range: opts.range });
|
|
691
|
+
if (opts.out) {
|
|
692
|
+
writeFileSync(opts.out, svg);
|
|
693
|
+
console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
|
|
694
|
+
console.log(`${c.dim}Tip: open in a browser, right-click → Save as PNG. Or drop straight into a Discord message — it'll render inline.${c.reset}`);
|
|
695
|
+
} else {
|
|
696
|
+
process.stdout.write(svg);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Privacy commands ─────────────────────────────────────────────────────
|
|
701
|
+
//
|
|
702
|
+
// `claude-rpc private` → add current cwd to ~/.claude-rpc/private-list.json
|
|
703
|
+
// `claude-rpc public` → remove current cwd
|
|
704
|
+
// `claude-rpc privacy` → show resolved visibility for current cwd + listed paths
|
|
705
|
+
//
|
|
706
|
+
// Per-project overrides live in <project>/.claude-rpc.json and take priority
|
|
707
|
+
// over the runtime list. See src/privacy.js for the full resolution chain.
|
|
708
|
+
|
|
709
|
+
function doPrivate() {
|
|
710
|
+
const cwd = process.cwd();
|
|
711
|
+
const list = addPrivateCwd(cwd);
|
|
712
|
+
console.log(`${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} marked private`);
|
|
713
|
+
console.log(`${c.dim} ${list.length} ${list.length === 1 ? 'path' : 'paths'} in the private list. Daemon picks it up within ~5 min (cache TTL) or restart.${c.reset}`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function doPublic() {
|
|
717
|
+
const cwd = process.cwd();
|
|
718
|
+
const before = listPrivateCwds().length;
|
|
719
|
+
const list = removePrivateCwd(cwd);
|
|
720
|
+
if (list.length === before) {
|
|
721
|
+
console.log(`${c.yellow}!${c.reset} ${c.cyan}${cwd}${c.reset} wasn't in the private list`);
|
|
722
|
+
} else {
|
|
723
|
+
console.log(`${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} removed from the private list`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function doPrivacy() {
|
|
728
|
+
const cwd = process.cwd();
|
|
729
|
+
const cfg = loadConfig();
|
|
730
|
+
const { visibility, projectName, reason } = resolveVisibility(cwd, cfg);
|
|
731
|
+
const color = visibility === 'hidden' ? c.red : visibility === 'name-only' ? c.yellow : c.green;
|
|
732
|
+
console.log('');
|
|
733
|
+
console.log(` ${c.bold}privacy${c.reset} ${c.dim}for${c.reset} ${c.cyan}${cwd}${c.reset}`);
|
|
734
|
+
console.log(` ${c.dim}visibility:${c.reset} ${color}${visibility}${c.reset} ${c.dim}(${reason})${c.reset}`);
|
|
735
|
+
if (projectName) console.log(` ${c.dim}alias: ${c.reset} ${projectName}`);
|
|
736
|
+
const list = listPrivateCwds();
|
|
737
|
+
if (list.length) {
|
|
738
|
+
console.log('');
|
|
739
|
+
console.log(` ${c.bold}private-list${c.reset} ${c.dim}(${list.length} ${list.length === 1 ? 'path' : 'paths'})${c.reset}`);
|
|
740
|
+
for (const p of list) console.log(` ${p === cwd ? c.cyan + '●' + c.reset : ' '} ${p}`);
|
|
741
|
+
}
|
|
742
|
+
console.log('');
|
|
743
|
+
console.log(` ${c.dim}toggle: claude-rpc private / claude-rpc public${c.reset}`);
|
|
744
|
+
console.log(` ${c.dim}per-proj: drop a {"private": true} into .claude-rpc.json at the repo root${c.reset}`);
|
|
745
|
+
console.log('');
|
|
746
|
+
}
|
|
747
|
+
|
|
623
748
|
function tailLog() {
|
|
624
749
|
if (!existsSync(LOG_PATH)) {
|
|
625
750
|
console.log(`${c.yellow}No log yet at ${LOG_PATH}${c.reset}`);
|
|
@@ -641,14 +766,85 @@ function tailLog() {
|
|
|
641
766
|
// file rotated
|
|
642
767
|
lastSize = buf.length;
|
|
643
768
|
}
|
|
644
|
-
} catch {}
|
|
769
|
+
} catch { /* read race vs rotation — next watchFile tick recovers */ }
|
|
645
770
|
});
|
|
646
771
|
}
|
|
647
772
|
|
|
773
|
+
// One-screen "where am I?" view. Goal: a user typing `claude-rpc` with no
|
|
774
|
+
// args sees in <24 lines what's happening and the four most useful next
|
|
775
|
+
// commands. Full command list lives behind --help.
|
|
776
|
+
function overview() {
|
|
777
|
+
const setUp = hasUserConfig();
|
|
778
|
+
const cfg = loadConfig();
|
|
779
|
+
const pid = daemonPid();
|
|
780
|
+
|
|
781
|
+
console.log('');
|
|
782
|
+
console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}v${VERSION} — Discord Rich Presence for Claude Code${c.reset}`);
|
|
783
|
+
console.log('');
|
|
784
|
+
|
|
785
|
+
if (!setUp) {
|
|
786
|
+
console.log(` ${c.yellow}○${c.reset} not configured yet`);
|
|
787
|
+
console.log('');
|
|
788
|
+
console.log(` Run ${c.cyan}claude-rpc setup${c.reset} to get started.`);
|
|
789
|
+
console.log('');
|
|
790
|
+
console.log(` ${c.dim}--help for the full command list${c.reset}`);
|
|
791
|
+
console.log('');
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Status line: daemon up/down + project + model + status verb.
|
|
796
|
+
const state = readState();
|
|
797
|
+
const aggregate = readAggregate();
|
|
798
|
+
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
799
|
+
const vars = buildVars(state, cfg, aggregate);
|
|
800
|
+
const dot = pid
|
|
801
|
+
? `${c.green}●${c.reset} running ${c.dim}pid ${pid}${c.reset}`
|
|
802
|
+
: `${c.yellow}○${c.reset} ${c.dim}daemon not running${c.reset}`;
|
|
803
|
+
if (pid) {
|
|
804
|
+
console.log(` ${dot} ${c.gray}·${c.reset} ${c.bold}${vars.modelPretty}${c.reset} in ${c.bold}${vars.project}${c.reset} ${c.gray}·${c.reset} ${statusColor(vars.status)}${vars.statusVerbose}${c.reset}`);
|
|
805
|
+
} else {
|
|
806
|
+
console.log(` ${dot}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (aggregate) {
|
|
810
|
+
console.log('');
|
|
811
|
+
console.log(` ${c.dim}today ${c.reset}${c.green}${vars.todayHours.padEnd(6)}${c.reset}${c.dim}active · ${vars.todayPromptsLabel} · ${vars.todayCostFmt}${c.reset}`);
|
|
812
|
+
console.log(` ${c.dim}streak ${c.reset}${c.magenta}${String(vars.streak).padEnd(6)}${c.reset}${c.dim}${vars.streak === 1 ? 'day' : 'days'} · longest ${vars.longestStreak} · ${vars.allHours} all-time${c.reset}`);
|
|
813
|
+
} else {
|
|
814
|
+
console.log('');
|
|
815
|
+
console.log(` ${c.dim}no stats yet — run ${c.reset}${c.cyan}claude-rpc scan${c.reset}${c.dim} to build aggregates${c.reset}`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Four most useful next commands. Doctor leads — it's the answer to
|
|
819
|
+
// "something looks wrong" without the user having to read anything.
|
|
820
|
+
console.log('');
|
|
821
|
+
const next = pid
|
|
822
|
+
? [['status', 'full dashboard with heatmap'],
|
|
823
|
+
['doctor', 'diagnose problems'],
|
|
824
|
+
['serve', 'open the web dashboard'],
|
|
825
|
+
['stop', 'stop the daemon']]
|
|
826
|
+
: (cfg.clientId && cfg.clientId !== '1234567890123456789')
|
|
827
|
+
? [['start', 'launch the daemon'],
|
|
828
|
+
['doctor', 'diagnose problems'],
|
|
829
|
+
['status', 'show current stats'],
|
|
830
|
+
['setup', 'register Claude Code hooks']]
|
|
831
|
+
: [['setup', 'install hooks and seed config'],
|
|
832
|
+
['doctor', 'diagnose problems'],
|
|
833
|
+
['start', 'launch the daemon'],
|
|
834
|
+
['status', 'show current stats']];
|
|
835
|
+
for (const [name, desc] of next) {
|
|
836
|
+
console.log(` ${c.cyan}${name.padEnd(8)}${c.reset} ${c.dim}→${c.reset} ${desc}`);
|
|
837
|
+
}
|
|
838
|
+
console.log('');
|
|
839
|
+
console.log(` ${c.dim}--help for the full command list · --version${c.reset}`);
|
|
840
|
+
console.log('');
|
|
841
|
+
}
|
|
842
|
+
|
|
648
843
|
function help() {
|
|
649
844
|
const cmds = [
|
|
650
845
|
['setup', 'Install Claude Code hooks (~/.claude/settings.json)'],
|
|
651
846
|
['uninstall', 'Remove Claude Code hooks'],
|
|
847
|
+
['upgrade-config', 'Re-run idempotent migrations on an existing config.json'],
|
|
652
848
|
['start', 'Start the Discord RPC daemon (detached)'],
|
|
653
849
|
['stop', 'Stop the daemon'],
|
|
654
850
|
['restart', 'Stop then start the daemon'],
|
|
@@ -659,8 +855,14 @@ function help() {
|
|
|
659
855
|
['preview', 'Show how each rotation frame renders right now'],
|
|
660
856
|
['scan', 'Incrementally scan ~/.claude/projects for all-time totals'],
|
|
661
857
|
['rescan', 'Force re-parse every transcript (ignores cache)'],
|
|
858
|
+
['backfill', 'Import transcripts from any folder (e.g. a backup)'],
|
|
662
859
|
['insights', 'Auto-generated insights from your history'],
|
|
663
860
|
['badge', 'Render a Shields-style SVG (--metric --range --out)'],
|
|
861
|
+
['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
|
|
862
|
+
['private', 'Mark the current directory as private (hide from Discord)'],
|
|
863
|
+
['public', 'Un-mark the current directory'],
|
|
864
|
+
['privacy', 'Show resolved visibility for the current directory'],
|
|
865
|
+
['doctor', 'Run a diagnostic checklist — common-failure triage'],
|
|
664
866
|
['tail', 'Tail the daemon log file'],
|
|
665
867
|
['daemon', 'Run daemon in foreground (debug)'],
|
|
666
868
|
];
|
|
@@ -679,6 +881,8 @@ function help() {
|
|
|
679
881
|
console.log('');
|
|
680
882
|
console.log(` ${c.dim}Tip: ${c.reset}edit ${c.cyan}config.json${c.reset} to customize rotation frames. Run ${c.cyan}claude-rpc preview${c.reset} to see the result without Discord.`);
|
|
681
883
|
console.log('');
|
|
884
|
+
console.log(` ${c.dim}Exit codes:${c.reset} ${c.dim}0 ok · 1 user error · 2 system error · 3 wrong state${c.reset}`);
|
|
885
|
+
console.log('');
|
|
682
886
|
}
|
|
683
887
|
|
|
684
888
|
// Packaged exe: `claude-rpc.exe` with no args → first-run install + start.
|
|
@@ -691,9 +895,16 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
691
895
|
// top-level await.
|
|
692
896
|
(async () => {
|
|
693
897
|
switch (cmd) {
|
|
898
|
+
case '--version':
|
|
899
|
+
case '-V':
|
|
900
|
+
case '-v': console.log(`claude-rpc ${VERSION}`); break;
|
|
901
|
+
case '--help':
|
|
902
|
+
case '-h':
|
|
903
|
+
case 'help': help(); break;
|
|
694
904
|
case 'setup': await runInstall({ exePath: EXE_PATH || process.execPath, withStartup: false }); break;
|
|
695
905
|
case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
|
|
696
906
|
case 'uninstall': await runUninstall(); break;
|
|
907
|
+
case 'upgrade-config': migrateConfig(); break;
|
|
697
908
|
case 'start': startDaemon(); break;
|
|
698
909
|
case 'stop': stopDaemon(); break;
|
|
699
910
|
case 'restart': restartDaemon(); break;
|
|
@@ -706,13 +917,23 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
706
917
|
case 'dump': showStatus(); break;
|
|
707
918
|
case 'today': showToday(); break;
|
|
708
919
|
case 'week': showWeek(); break;
|
|
709
|
-
case 'serve': await import('./server.js'); break;
|
|
920
|
+
case 'serve': await import('./server/index.js'); break;
|
|
710
921
|
case 'preview': showPreview(); break;
|
|
711
922
|
case 'vars': dumpVars(); break;
|
|
712
923
|
case 'scan': doScan(false); break;
|
|
713
924
|
case 'rescan': doScan(true); break;
|
|
925
|
+
case 'backfill': doBackfill(process.argv.slice(3)); break;
|
|
714
926
|
case 'insights': showInsights(); break;
|
|
715
927
|
case 'badge': doBadge(process.argv.slice(3)); break;
|
|
928
|
+
case 'card': await doCard(process.argv.slice(3)); break;
|
|
929
|
+
case 'private': doPrivate(); break;
|
|
930
|
+
case 'public': doPublic(); break;
|
|
931
|
+
case 'privacy': doPrivacy(); break;
|
|
932
|
+
case 'doctor': {
|
|
933
|
+
const { runDoctor } = await import('./doctor.js');
|
|
934
|
+
process.exit(runDoctor());
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
716
937
|
case 'tail':
|
|
717
938
|
case 'logs':
|
|
718
939
|
case 'log': tailLog(); break;
|
|
@@ -749,7 +970,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
749
970
|
console.warn(`refresh skipped: ${e.message}`);
|
|
750
971
|
}
|
|
751
972
|
const wasRunning = stopDaemon({ quiet: true });
|
|
752
|
-
try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch {}
|
|
973
|
+
try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch { /* state.json locked or already gone — next hook will recreate it */ }
|
|
753
974
|
if (wasRunning) {
|
|
754
975
|
// Brief wait for the OS to release the pid file before we spawn.
|
|
755
976
|
setTimeout(() => startDaemon(), 700);
|
|
@@ -757,8 +978,14 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
757
978
|
startDaemon();
|
|
758
979
|
}
|
|
759
980
|
}
|
|
981
|
+
} else if (!cmd) {
|
|
982
|
+
// True no-args invocation in dev/npm mode → one-screen overview.
|
|
983
|
+
// The packagedDefault branch above handles the SEA exe's "double-
|
|
984
|
+
// click with no args" install-and-start flow.
|
|
985
|
+
overview();
|
|
760
986
|
} else {
|
|
761
|
-
|
|
987
|
+
fail(`unknown command: ${cmd}`,
|
|
988
|
+
{ hint: 'run `claude-rpc --help` for the full list', code: EX_USER_ERROR });
|
|
762
989
|
}
|
|
763
990
|
}
|
|
764
991
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Config loader. One place that knows how to:
|
|
2
|
+
//
|
|
3
|
+
// 1. Read the user's config.json from disk.
|
|
4
|
+
// 2. Deep-merge it over DEFAULT_CONFIG so the user file only needs to
|
|
5
|
+
// hold *overrides* (a fresh user file is two lines: clientId and
|
|
6
|
+
// maybe appName).
|
|
7
|
+
// 3. Survive bad JSON / missing files / wrong types without crashing.
|
|
8
|
+
// Bad input logs a one-line warning and falls back to defaults.
|
|
9
|
+
// Critical for the daemon — an Electron-GUI mid-edit save used to
|
|
10
|
+
// hard-exit it via daemon.js's `process.exit(1)`. No more.
|
|
11
|
+
//
|
|
12
|
+
// Merge rule: plain objects deep-merge, arrays REPLACE. Arrays-as-deep-
|
|
13
|
+
// merge is rarely what anyone wants (a user rotation array becomes a
|
|
14
|
+
// spliced franken-array of theirs + defaults). Replacing matches what
|
|
15
|
+
// you'd expect from "I set this, it's mine."
|
|
16
|
+
//
|
|
17
|
+
// All callers (daemon, server/api, tui, cli) should go through
|
|
18
|
+
// `loadConfig()` rather than reading CONFIG_PATH directly.
|
|
19
|
+
|
|
20
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
21
|
+
import { CONFIG_PATH } from './paths.js';
|
|
22
|
+
import { DEFAULT_CONFIG } from './default-config.js';
|
|
23
|
+
|
|
24
|
+
// "Has the user run setup?" — a separate signal from `loadConfig` because
|
|
25
|
+
// loadConfig now always returns merged defaults (the daemon needs them
|
|
26
|
+
// even if the file isn't there yet). Callers that want to distinguish
|
|
27
|
+
// "never been set up" from "everything default" check this instead.
|
|
28
|
+
export function hasUserConfig(path = CONFIG_PATH) {
|
|
29
|
+
return existsSync(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPlainObject(v) {
|
|
33
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Deep-merge `over` onto `base`. Plain objects merge recursively; arrays
|
|
37
|
+
// and primitives from `over` replace whatever was in `base`. Returns a
|
|
38
|
+
// fresh object — neither input is mutated. Used for layering user config
|
|
39
|
+
// over DEFAULT_CONFIG and (later) per-project overrides over user config.
|
|
40
|
+
export function mergeConfig(base, over) {
|
|
41
|
+
if (over === undefined || over === null) return structuredClone(base);
|
|
42
|
+
if (!isPlainObject(base) || !isPlainObject(over)) return structuredClone(over);
|
|
43
|
+
const out = {};
|
|
44
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(over)]);
|
|
45
|
+
for (const k of keys) {
|
|
46
|
+
const a = base[k];
|
|
47
|
+
const b = over[k];
|
|
48
|
+
if (b === undefined) {
|
|
49
|
+
out[k] = structuredClone(a);
|
|
50
|
+
} else if (isPlainObject(a) && isPlainObject(b)) {
|
|
51
|
+
out[k] = mergeConfig(a, b);
|
|
52
|
+
} else {
|
|
53
|
+
out[k] = structuredClone(b);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Read + merge. `onError(message)` is called when the user config can't
|
|
60
|
+
// be parsed — caller decides whether to log to stdout, daemon.log, or
|
|
61
|
+
// nothing. Returning the merged-defaults object is the contract: never
|
|
62
|
+
// throw, never exit. Worst case we render with shipped defaults.
|
|
63
|
+
//
|
|
64
|
+
// path defaults to CONFIG_PATH so all daemon-like callers can drop the
|
|
65
|
+
// `readFileSync(CONFIG_PATH, ...)` boilerplate.
|
|
66
|
+
export function loadConfig({ path = CONFIG_PATH, onError } = {}) {
|
|
67
|
+
if (!existsSync(path)) {
|
|
68
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = readFileSync(path, 'utf8');
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (onError) onError(`config read failed at ${path}: ${e.message} — falling back to defaults`);
|
|
75
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (onError) onError(`config parse failed at ${path}: ${e.message} — falling back to defaults`);
|
|
82
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
83
|
+
}
|
|
84
|
+
if (!isPlainObject(parsed)) {
|
|
85
|
+
if (onError) onError(`config at ${path} is not an object — falling back to defaults`);
|
|
86
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
87
|
+
}
|
|
88
|
+
return mergeConfig(DEFAULT_CONFIG, parsed);
|
|
89
|
+
}
|