@promptctl/cc-candybar 1.0.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,127 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // XDG Base Directory split:
6
+ // - daemon runtime (pid, log, heap snapshots, spawn.lock) → $XDG_STATE_HOME/cc-candybar
7
+ // - filesystem caches (git, usage, last-render) → $XDG_CACHE_HOME/cc-candybar
8
+ //
9
+ // Both default per the XDG spec ($HOME/.local/state and $HOME/.cache). Empty
10
+ // env vars fall through to the defaults. The two roots are kept separate so
11
+ // users can `rm -rf` either one without taking the other down.
12
+ //
13
+ // The socket path is NOT derived from XDG_STATE_HOME — see socketPath() below.
14
+ // The Rust client mirrors both path families in rust-client/src/main.rs; both
15
+ // must agree or the client can't find the daemon's socket.
16
+
17
+ function xdgEnv(name: string): string | undefined {
18
+ const v = process.env[name];
19
+ return v && v.length > 0 ? v : undefined;
20
+ }
21
+
22
+ export function stateDir(): string {
23
+ const base =
24
+ xdgEnv("XDG_STATE_HOME") ?? path.join(os.homedir(), ".local", "state");
25
+ return path.join(base, "cc-candybar");
26
+ }
27
+
28
+ export function cacheDir(): string {
29
+ const base = xdgEnv("XDG_CACHE_HOME") ?? path.join(os.homedir(), ".cache");
30
+ return path.join(base, "cc-candybar");
31
+ }
32
+
33
+ export function configDir(): string {
34
+ const base = xdgEnv("XDG_CONFIG_HOME") ?? path.join(os.homedir(), ".config");
35
+ return path.join(base, "cc-candybar");
36
+ }
37
+
38
+ // `daemonDir` kept as the canonical name for the runtime root so existing
39
+ // callers (limits.ts, server.ts) don't need to learn a new term. It now
40
+ // resolves under $XDG_STATE_HOME/cc-candybar instead of ~/.claude/powerline.
41
+ export function daemonDir(): string {
42
+ return stateDir();
43
+ }
44
+
45
+ // [LAW:one-source-of-truth] The socket IS the daemon's identity — same as
46
+ // tmux's /tmp/tmux-<uid>/default model. UID is kernel identity: immutable,
47
+ // not overridable by any env var. /tmp is guaranteed on every Unix host and
48
+ // is cleared on reboot, which is fine — the daemon doesn't survive reboots.
49
+ // CC_CANDYBAR_SOCKET is the only explicit override for intentional isolation
50
+ // (tests, dev, multiple intentional instances).
51
+ export function socketPath(): string {
52
+ const override = process.env.CC_CANDYBAR_SOCKET;
53
+ if (override) return override;
54
+ const uid = os.userInfo().uid;
55
+ return path.join("/tmp", `cc-candybar-${uid}`, "socket");
56
+ }
57
+
58
+ // [LAW:single-enforcer] The daemon is the sole creator of the socket parent
59
+ // directory. If we enforce "this dir is uid==me + mode 0700 + not a symlink"
60
+ // at bind time, then by induction every successful bind happened under a
61
+ // trusted parent — and any client reaching the socket via the canonical path
62
+ // reached one our daemon owns. A foreign-uid or world-writable squat triggers
63
+ // a refusal, turning a silent-MITM attempt into a visible daemon failure (the
64
+ // client sees no response, the user sees the last cached render).
65
+ //
66
+ // Throws on any unsafe state; callers are expected to let the daemon exit.
67
+ // [LAW:no-silent-fallbacks] do NOT auto-rmdir + recreate — a wrong-owner dir
68
+ // is hostile state, not a recoverable error.
69
+ export function ensureSocketParentSafe(sockPath: string): void {
70
+ const parent = path.dirname(sockPath);
71
+ // mkdir with mode 0o700; harmless if already exists (mode is not applied
72
+ // post-hoc — we verify it next).
73
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
74
+
75
+ const st = fs.lstatSync(parent);
76
+ if (st.isSymbolicLink()) {
77
+ throw new Error(`socket parent is a symlink: ${parent}`);
78
+ }
79
+ if (!st.isDirectory()) {
80
+ throw new Error(`socket parent is not a directory: ${parent}`);
81
+ }
82
+ const myUid = os.userInfo().uid;
83
+ // getuid is undefined on Windows; we don't ship there, but guard cheaply.
84
+ if (typeof myUid === "number" && st.uid !== myUid) {
85
+ throw new Error(
86
+ `socket parent is not owned by uid ${myUid}: ${parent} (owner uid=${st.uid})`,
87
+ );
88
+ }
89
+ // Reject any group/world bits — only the owner may traverse.
90
+ if ((st.mode & 0o077) !== 0) {
91
+ throw new Error(
92
+ `socket parent has unsafe permissions: ${parent} (mode=${(st.mode & 0o777).toString(8)}, expected 0700)`,
93
+ );
94
+ }
95
+ // If a stale socket file is a symlink, refuse — an attacker who briefly
96
+ // had write access to a previously-permissive dir could have planted a
97
+ // symlink even after we tighten perms.
98
+ try {
99
+ const sst = fs.lstatSync(sockPath);
100
+ if (sst.isSymbolicLink()) {
101
+ throw new Error(`socket path is a symlink: ${sockPath}`);
102
+ }
103
+ } catch (e) {
104
+ const code = (e as NodeJS.ErrnoException).code;
105
+ if (code !== "ENOENT") throw e;
106
+ }
107
+ }
108
+
109
+ export function pidPath(): string {
110
+ return path.join(stateDir(), "pid");
111
+ }
112
+
113
+ export function sessionStatePath(): string {
114
+ return path.join(stateDir(), "session-state.json");
115
+ }
116
+
117
+ // [LAW:single-enforcer] Caller-side spawn dedup. Held by a client *only* during
118
+ // the spawn window — never for the daemon's lifetime. The actual one-daemon
119
+ // invariant is enforced by atomic bind() on socketPath() inside the daemon.
120
+ // This file is a thundering-herd optimization, not the load-bearing lock.
121
+ export function spawnLockPath(): string {
122
+ return path.join(stateDir(), "spawn.lock");
123
+ }
124
+
125
+ export function logPath(): string {
126
+ return path.join(stateDir(), "daemon.log");
127
+ }
@@ -0,0 +1,235 @@
1
+ import type { Socket } from "node:net";
2
+ import type { ClaudeHookData } from "../utils/claude";
3
+ import type { StatsSnapshot } from "./stats";
4
+ import type { DebugSnapshot, DebugWhat } from "./debug-types";
5
+
6
+ // [LAW:types-are-the-program] PROTOCOL_VERSION encodes one thing:
7
+ // "old-client × new-daemon (or vice versa) cannot communicate." It moves on
8
+ // BREAKING changes only — never on additive ones. The two cases are
9
+ // genuinely different theorems and the version field carries the stronger:
10
+ // incompatibility, not growth.
11
+ //
12
+ // **Additive** (no bump):
13
+ // - Adding a new request `kind`. Old daemons reject the unknown kind via
14
+ // the existing BAD_REQUEST fallthrough; old clients never send it.
15
+ // - Adding a new optional field that older parsers ignore safely.
16
+ // - Adding a new response variant produced only in response to a new kind.
17
+ //
18
+ // **Breaking** (bump):
19
+ // - Changing the semantics or required shape of an existing kind.
20
+ // - Removing a kind or field old clients depend on.
21
+ // - Renaming a wire field.
22
+ //
23
+ // The 452-corpse precedent (kz8.5) makes this discipline load-bearing: every
24
+ // bump forces every running statusbar through VERSION_MISMATCH until its
25
+ // session restarts, because the spiral-breaker contract refuses to kick on a
26
+ // permanent error. A bump for an additive change taxes every user with
27
+ // blank-statusbar minutes for a feature their session doesn't even use.
28
+ // Don't bump for growth.
29
+ export const PROTOCOL_VERSION = 3;
30
+
31
+ export interface RenderRequest {
32
+ v: number;
33
+ kind: "render";
34
+ hookData: ClaudeHookData;
35
+ args: string[];
36
+ cwd: string;
37
+ // [LAW:single-enforcer] Terminal width is captured at the trust boundary
38
+ // (the client's env, where COLUMNS/ioctl are meaningful) and trusted by the
39
+ // daemon. Absence means the client couldn't determine it. The wire field is
40
+ // typed `number` but the wire is untrusted JSON — callers MUST run it
41
+ // through sanitizeTermCols at the receive boundary before using it.
42
+ termCols?: number;
43
+ }
44
+
45
+ // [LAW:no-defensive-null-guards] exception: trust boundary. The wire is
46
+ // untrusted JSON; downstream code treats termCols as an integer in a sane
47
+ // range. Validate once here so the type's promise is true.
48
+ //
49
+ // Pathologically large values are capped (not rejected) so a future
50
+ // genuinely-huge terminal still renders — 10000 is two orders of magnitude
51
+ // above the largest plausible real terminal.
52
+ const MAX_TERM_COLS = 10000;
53
+ export function sanitizeTermCols(v: unknown): number | undefined {
54
+ if (typeof v !== "number") return undefined;
55
+ if (!Number.isFinite(v)) return undefined;
56
+ const n = Math.floor(v);
57
+ if (n <= 0) return undefined;
58
+ return n > MAX_TERM_COLS ? MAX_TERM_COLS : n;
59
+ }
60
+
61
+ export interface ShutdownRequest {
62
+ v: number;
63
+ kind: "shutdown";
64
+ }
65
+
66
+ export interface ClickRequest {
67
+ v: number;
68
+ kind: "click";
69
+ verb: string;
70
+ value: string;
71
+ }
72
+
73
+ export interface StatsRequest {
74
+ v: number;
75
+ kind: "stats";
76
+ }
77
+
78
+ // [LAW:types-are-the-program] The `what` discriminator carries the response
79
+ // shape forward — a client requesting "vars" gets vars data, "segments" gets
80
+ // segments data, "config" gets config data. No string-keyed lookup on the
81
+ // client side; route by structure. See DebugSnapshot in ./debug-types.
82
+ export interface DebugRequest {
83
+ v: number;
84
+ kind: "debug";
85
+ what: DebugWhat;
86
+ }
87
+
88
+ export type Request =
89
+ | RenderRequest
90
+ | ShutdownRequest
91
+ | StatsRequest
92
+ | ClickRequest
93
+ | DebugRequest;
94
+
95
+ export type Response =
96
+ | { ok: true; output: string }
97
+ | { ok: true; stats: StatsSnapshot }
98
+ // [LAW:types-are-the-program] DebugSnapshot is itself a discriminated union
99
+ // on `what`, so the response type carries the requested kind through to the
100
+ // client. A `{ ok: true; debug: { what: "vars"; vars: [...] } }` shape is
101
+ // self-describing — the client doesn't need to remember which `what` it sent.
102
+ | { ok: true; debug: DebugSnapshot }
103
+ // [LAW:types-are-the-program] `daemonV` is the daemon's own
104
+ // PROTOCOL_VERSION, echoed on every error response so the client can render
105
+ // a meaningful diagnostic on VERSION_MISMATCH without parsing the human
106
+ // message. Optional for back-compat: older daemons (or test stubs) that
107
+ // omit it are still parseable by current clients.
108
+ | { ok: false; error: string; code: ErrorCode; daemonV?: number };
109
+
110
+ // [LAW:types-are-the-program] The wire-level discriminator splits failures
111
+ // into two recovery classes: TIMEOUT is transient — the daemon is alive but
112
+ // slow, so a respawn or retry has a real chance of recovering. Every other
113
+ // code is permanent — respawning would hit the same response identically
114
+ // (the daemon refuses the request for VERSION_MISMATCH or BAD_REQUEST, or
115
+ // fails internally for RENDER_FAILED in a way the spawn loop cannot cure),
116
+ // so the spiral-breaker contract requires the client NOT to kick. The
117
+ // kick-vs-no-kick decision is encoded off this code, not off the error
118
+ // string.
119
+ export type ErrorCode =
120
+ | "VERSION_MISMATCH"
121
+ | "TIMEOUT"
122
+ | "RENDER_FAILED"
123
+ | "BAD_REQUEST";
124
+
125
+ // [LAW:types-are-the-program] A typed error class for failures that originate
126
+ // inside the wire-protocol layer (oversized frame, JSON decode failure).
127
+ // Callers in src/daemon/client-transport.ts branch on `e instanceof ProtocolError`
128
+ // to classify these as `permanent/malformed_response` — far more robust than
129
+ // substring-matching against the message, which would silently drift if
130
+ // Node's JSON.parse error wording changes. The class is small but
131
+ // load-bearing: it makes the kick-vs-show-error decision a structural
132
+ // property of the thrown value, not a property of its english string.
133
+ export class ProtocolError extends Error {
134
+ constructor(message: string) {
135
+ super(message);
136
+ this.name = "ProtocolError";
137
+ }
138
+ }
139
+
140
+ // 4-byte big-endian length prefix + UTF-8 JSON body. Length-prefix beats
141
+ // newline-delimited because error messages may contain embedded newlines and
142
+ // we'd rather not parse them out of-band.
143
+ //
144
+ // [LAW:one-source-of-truth] FRAME_HEADER_BYTES and MAX_FRAME_BYTES are part
145
+ // of the wire contract the Rust client mirrors (rust-client/src/main.rs);
146
+ // scripts/check-protocol.mjs diffs them, so they must stay named consts.
147
+ export const FRAME_HEADER_BYTES = 4;
148
+ export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
149
+
150
+ export function encodeFrame(value: unknown): Buffer {
151
+ const body = Buffer.from(JSON.stringify(value), "utf8");
152
+ const header = Buffer.alloc(FRAME_HEADER_BYTES);
153
+ header.writeUInt32BE(body.length, 0);
154
+ return Buffer.concat([header, body]);
155
+ }
156
+
157
+ // Streaming frame reader. Calls `onFrame` for each complete frame. Caller
158
+ // owns lifecycle — call `feed` with each chunk; reader keeps a buffer.
159
+ export function makeFrameReader(
160
+ onFrame: (frame: unknown) => void,
161
+ onError: (err: Error) => void,
162
+ ) {
163
+ let buf = Buffer.alloc(0);
164
+ return function feed(chunk: Buffer): void {
165
+ buf = Buffer.concat([buf, chunk]);
166
+ while (buf.length >= FRAME_HEADER_BYTES) {
167
+ const len = buf.readUInt32BE(0);
168
+ // Hard cap to defend against a runaway sender allocating gigabytes.
169
+ // [LAW:types-are-the-program] ProtocolError carries the discriminator
170
+ // structurally — interpretException routes on `instanceof`, not on a
171
+ // brittle string match against the message body.
172
+ if (len > MAX_FRAME_BYTES) {
173
+ onError(new ProtocolError(`frame too large: ${len}`));
174
+ return;
175
+ }
176
+ if (buf.length < FRAME_HEADER_BYTES + len) return;
177
+ const body = buf.subarray(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + len);
178
+ buf = buf.subarray(FRAME_HEADER_BYTES + len);
179
+ try {
180
+ onFrame(JSON.parse(body.toString("utf8")));
181
+ } catch (e) {
182
+ // JSON.parse throws SyntaxError; wrap as ProtocolError so the
183
+ // recovery class is structurally typed (not message-matched).
184
+ // Preserve the original cause for diagnostic logging.
185
+ const wrapped = new ProtocolError(
186
+ e instanceof Error ? e.message : String(e),
187
+ );
188
+ if (e instanceof Error) {
189
+ wrapped.cause = e;
190
+ }
191
+ onError(wrapped);
192
+ return;
193
+ }
194
+ }
195
+ };
196
+ }
197
+
198
+ // Send one frame and await one response, with a hard total budget. Resolves
199
+ // to the parsed response or rejects on timeout / parse error / socket error.
200
+ export function sendOne(
201
+ sock: Socket,
202
+ req: Request,
203
+ totalBudgetMs: number,
204
+ ): Promise<Response> {
205
+ return new Promise((resolve, reject) => {
206
+ let settled = false;
207
+ const finish = (fn: () => void) => {
208
+ if (settled) return;
209
+ settled = true;
210
+ clearTimeout(timer);
211
+ sock.removeAllListeners();
212
+ try {
213
+ fn();
214
+ } catch {}
215
+ };
216
+ const timer = setTimeout(() => {
217
+ finish(() => {
218
+ sock.destroy();
219
+ reject(new Error("TIMEOUT"));
220
+ });
221
+ }, totalBudgetMs);
222
+
223
+ const reader = makeFrameReader(
224
+ (frame) => finish(() => resolve(frame as Response)),
225
+ (err) => finish(() => reject(err)),
226
+ );
227
+ sock.on("data", reader);
228
+ sock.on("error", (err) => finish(() => reject(err)));
229
+ sock.on("close", () =>
230
+ finish(() => reject(new Error("socket closed before response"))),
231
+ );
232
+
233
+ sock.write(encodeFrame(req));
234
+ });
235
+ }