@promptctl/cc-candybar 1.42.1 → 1.43.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 (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.42.1",
3
+ "version": "1.43.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -57,7 +57,6 @@
57
57
  "dist",
58
58
  "bin",
59
59
  "plugin",
60
- "src",
61
60
  "schema",
62
61
  "README.md",
63
62
  "LICENSE"
@@ -91,9 +90,9 @@
91
90
  "mobx": "^6.15.0"
92
91
  },
93
92
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.42.1",
95
- "@promptctl/cc-candybar-darwin-x64": "1.42.1",
96
- "@promptctl/cc-candybar-linux-x64": "1.42.1",
97
- "@promptctl/cc-candybar-linux-arm64": "1.42.1"
93
+ "@promptctl/cc-candybar-darwin-arm64": "1.43.0",
94
+ "@promptctl/cc-candybar-darwin-x64": "1.43.0",
95
+ "@promptctl/cc-candybar-linux-x64": "1.43.0",
96
+ "@promptctl/cc-candybar-linux-arm64": "1.43.0"
98
97
  }
99
98
  }
package/src/check.ts DELETED
@@ -1,478 +0,0 @@
1
- // [LAW:verifiable-goals] `cc-candybar check [path]` — the authoring agent's eyes.
2
- // Config diagnostics otherwise surface VISUALLY (composeWithDiagnostics renders
3
- // error/warning icons into the bar), a channel a blind config author never sees.
4
- // This command runs the production pipeline and projects its verdict onto a
5
- // text + exit-code contract a script can close its own loop on:
6
- // 0 — config loads and renders (warnings, if any, on stderr)
7
- // 1 — config is invalid (parse / validate / register / render failure)
8
- // 2 — usage error or a named file could not be read
9
- //
10
- // [LAW:single-enforcer] No parallel validation path: the verdict is reached
11
- // through the exact functions the daemon runs (RenderCache.reloadInto →
12
- // buildState, then the per-request render in server.ts) — resolveDslConfigPath →
13
- // detectConfigCollisions → loadConfig → validateConfig → registerDslConfig →
14
- // deriveActionValidators → renderDsl. "check passes" and "the daemon renders"
15
- // cannot diverge, because they are one code path.
16
-
17
- import fs from "node:fs";
18
- import path from "node:path";
19
- import process from "node:process";
20
- import {
21
- loadConfig,
22
- validateConfig,
23
- resolveDslConfigPath,
24
- detectConfigCollisions,
25
- ConfigError,
26
- } from "./config/dsl-loader.js";
27
- import { expandHome } from "./config/loader/discovery.js";
28
- import { DEFAULT_DSL_CONFIG } from "./config/default-dsl-config.js";
29
- import { VariableStore } from "./var-system/store.js";
30
- import { SourceRegistry } from "./var-system/sources.js";
31
- import { SessionState } from "./daemon/session-state.js";
32
- import { registerDslConfig, renderDsl } from "./dsl/render.js";
33
- import { deriveActionValidators } from "./daemon/verbs/state-validators.js";
34
- import { lookKeyByName } from "./themes/policy.js";
35
- import { paletteForThemeName } from "./themes/palette-resolvers.js";
36
- import {
37
- resolveEffectiveGlobals,
38
- type EffectiveGlobals,
39
- } from "./daemon/render-payload.js";
40
-
41
- // [LAW:no-ambient-temporal-coupling] A fixed width keeps the verdict a function
42
- // of the config alone, not of whichever terminal invoked the check. Templates
43
- // evaluate in full before any width-driven wrap/pagination, so width shapes
44
- // layout, never diagnostics.
45
- const CHECK_WIDTH = 200;
46
-
47
- // One faked Claude Code hook event, shaped like the daemon's augmented payload
48
- // (see src/daemon/render-payload.ts) — the `input` vars read out of it by their
49
- // dotted `path`. [LAW:verifiable-goals] It is deliberately RICH (dirty git with
50
- // every worktree count, an upstream, a stash, a recent commit; home set; live
51
- // session/today/context/metrics/rate-limit data) so gated segments actually
52
- // RENDER their content instead of gating off. A minimal payload would let a
53
- // field-name typo in the git/directory/metrics/budget branches slip through —
54
- // those branches only run when their data is present.
55
- //
56
- // `effective` is threaded in exactly as the daemon threads it (server.ts
57
- // resolves one EffectiveGlobals struct per render and feeds BOTH the
58
- // payload's `*.effective` fields and BuildLineOptions/basePalette below)
59
- // [LAW:one-source-of-truth].
60
- //
61
- // test/example-configs.test.ts asserts rendered content against these literal
62
- // values (780s → "◷ 13m", cost $0.39, version 1.15.0, …); changing one here
63
- // fails that suite loudly rather than drifting silently.
64
- export function checkPayload(
65
- effective: EffectiveGlobals,
66
- ): Record<string, unknown> {
67
- const home = "/home/tester";
68
- const nowSec = Math.floor(Date.now() / 1000);
69
- return {
70
- hook_event_name: "Status",
71
- session_id: "test0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b",
72
- version: "1.15.0",
73
- home,
74
- cwd: `${home}/code/cc-candybar/src`,
75
- transcript_path: `${home}/.claude/projects/x/test.jsonl`,
76
- model: { id: "claude-opus-4-8", display_name: "Opus 4.8" },
77
- workspace: {
78
- current_dir: `${home}/code/cc-candybar/src`,
79
- project_dir: `${home}/code/cc-candybar`,
80
- },
81
- git: {
82
- repoName: "cc-candybar",
83
- repoUrl: "https://github.com/promptctl/cc-candybar",
84
- branch: "main",
85
- sha: "abc1234",
86
- ahead: 2,
87
- behind: 1,
88
- staged: 3,
89
- unstaged: 2,
90
- untracked: 1,
91
- conflicts: 0,
92
- upstream: "origin/main",
93
- stash: 1,
94
- status: "dirty",
95
- operation: "rebase",
96
- timeSinceCommit: 780,
97
- },
98
- session: { cost: 0.39, tokens: 241400 },
99
- today: { cost: 12.5, tokens: 3_400_000 },
100
- context: { totalTokens: 48487, contextLeft: 24 },
101
- metrics: {
102
- lastResponseTime: 8.2,
103
- responseTime: 4.2,
104
- sessionDuration: 930,
105
- messageCount: 8,
106
- linesAdded: 512,
107
- linesRemoved: 88,
108
- },
109
- block: { nativeUtilization: 63, resetsAt: nowSec + 2 * 3600 },
110
- weekly: { percentage: 21, resetsAt: nowSec + 5 * 86400 },
111
- cache: { expiresAt: nowSec + 15 * 60 },
112
- tmux: { session: "work" },
113
- // `ssh: true` for the same reason `tmux.session` is populated: this
114
- // fixture deliberately satisfies every gate so a when-gated segment
115
- // RENDERS and its template gets checked. A local-looking fixture would
116
- // gate the host segment off and let a typo inside it ship.
117
- host: { name: "tester-box", user: "tester", ssh: true },
118
- theme: { effective: effective.theme },
119
- look: { effective: effective.look },
120
- // [LAW:one-source-of-truth] Was missing here even though EffectiveGlobals
121
- // already carried `preset` — a pre-existing gap this ticket's own fixture
122
- // needs closed: a preset trigger's `.preset.effective` label and
123
- // brandon-layout-edit-2gc.5's `.preset.customized` gate both silently
124
- // fell back to their declared defaults ("" / false) rather than the
125
- // resolved value, exactly the drift the sibling `*.effective` fields
126
- // already guard against.
127
- preset: {
128
- effective: effective.preset,
129
- customized: effective.presetCustomized,
130
- },
131
- style: { effective: effective.style },
132
- charset: { effective: effective.charset },
133
- colorCompatibility: { effective: effective.colorCompatibility },
134
- autoWrap: { effective: effective.autoWrap },
135
- padding: { effective: effective.padding },
136
- };
137
- }
138
-
139
- // [LAW:dataflow-not-control-flow] The check result is DATA — a pure function of
140
- // the target file's contents — discriminated into the three outcomes the exit-
141
- // code contract projects. `checkConfig` carries the decision; `runCheck` only
142
- // maps it to (streams, exit), so the contract is testable without spawning a
143
- // process or stubbing process.exit.
144
- //
145
- // `configPath` null means the bundled default was checked (no config file
146
- // found — the daemon renders the same default in that state).
147
- export type CheckOutcome =
148
- | {
149
- readonly kind: "clean";
150
- readonly configPath: string | null;
151
- readonly warnings: readonly string[];
152
- readonly rendered: string;
153
- }
154
- | {
155
- readonly kind: "fatal";
156
- readonly configPath: string | null;
157
- readonly message: string;
158
- readonly warnings: readonly string[];
159
- }
160
- | {
161
- readonly kind: "unreadable";
162
- readonly path: string;
163
- readonly message: string;
164
- };
165
-
166
- // Run the daemon's load-and-render pipeline against one config target.
167
- //
168
- // With no target, the path resolves exactly as the daemon resolves it
169
- // (resolveDslConfigPath: $CC_CANDYBAR_CONFIG → project/cwd → XDG), so the file
170
- // this checks IS the file the daemon would load from this directory.
171
- //
172
- // [LAW:no-silent-failure] With an explicit target, the named file must exist
173
- // and be readable — a missing file is `unreadable`, never a fall-through to the
174
- // bundled default. (The daemon's --config-to-missing-file behavior — render the
175
- // default, watch for the file to appear — is liveness for a long-running
176
- // renderer; a verdict command must not report "clean" about a file it never
177
- // read.)
178
- export function checkConfig(
179
- target: string | undefined,
180
- cwd: string = process.cwd(),
181
- ): CheckOutcome {
182
- // [LAW:one-source-of-truth] No pre-read: the ONE content read of the config
183
- // file is the readFileSync inside loadConfig. Readability is established by
184
- // the same read that parses (no double I/O); the catch below classifies its
185
- // errno failure as `unreadable`. The explicit-target statSync is a metadata
186
- // probe at the argv trust boundary, not a second read: a directory target
187
- // (`check .`) fails read() with a path-less EISDIR the catch could not
188
- // attribute, so the not-a-file usage error is decided here.
189
- const configPath =
190
- target !== undefined
191
- ? path.resolve(expandHome(target))
192
- : resolveDslConfigPath(cwd, cwd);
193
- if (target !== undefined && configPath !== null) {
194
- // throwIfNoEntry suppresses only ENOENT (left for the content read to
195
- // classify); EACCES/EPERM on the probe itself is equally "could not read
196
- // the named file" — same outcome, not an uncaught stack.
197
- let st: fs.Stats | undefined;
198
- try {
199
- st = fs.statSync(configPath, { throwIfNoEntry: false });
200
- } catch (e) {
201
- return {
202
- kind: "unreadable",
203
- path: configPath,
204
- message: e instanceof Error ? e.message : String(e),
205
- };
206
- }
207
- if (st !== undefined && !st.isFile()) {
208
- return {
209
- kind: "unreadable",
210
- path: configPath,
211
- message: "not a file",
212
- };
213
- }
214
- }
215
-
216
- // [LAW:dataflow-not-control-flow] Collision detection runs independent of
217
- // load success — mirror of RenderCache.reloadInto: even if the .json5 fails
218
- // to parse, the author still wants to know a shadowed .json sibling exists.
219
- const warnings: string[] = [];
220
- const collision = detectConfigCollisions(cwd, cwd);
221
- if (collision !== null) warnings.push(collision);
222
-
223
- try {
224
- const rendered = loadRegisterRender(configPath, cwd, warnings);
225
- return { kind: "clean", configPath, warnings, rendered };
226
- } catch (e) {
227
- // A filesystem error on the config file itself (ENOENT/EACCES from
228
- // loadConfig's read — errno errors carry the failing `.path`, which is the
229
- // discriminator against deeper fs failures) is the `unreadable` outcome:
230
- // the named file could not be read at all, distinct from a file that read
231
- // but is invalid. [LAW:no-silent-failure] — never a fall-through to the
232
- // bundled default. Duck-typed, not `instanceof Error`: fs errors can cross
233
- // a realm boundary (jest/graceful-fs), where instanceof lies.
234
- const errno = e as Partial<NodeJS.ErrnoException> | null;
235
- if (
236
- configPath !== null &&
237
- typeof errno === "object" &&
238
- errno !== null &&
239
- typeof errno.code === "string" &&
240
- errno.path === configPath &&
241
- typeof errno.message === "string"
242
- ) {
243
- return { kind: "unreadable", path: configPath, message: errno.message };
244
- }
245
- // Same classification RenderCache.reloadInto applies: ConfigError and
246
- // register/render throws (template parse, MissingFieldError, action arity)
247
- // are all author-facing diagnostics — the daemon would surface each via
248
- // composeWithDiagnostics, so check surfaces each as fatal text.
249
- const message =
250
- e instanceof ConfigError
251
- ? e.message
252
- : e instanceof Error
253
- ? e.message
254
- : String(e);
255
- return { kind: "fatal", configPath, message, warnings };
256
- }
257
- }
258
-
259
- // The buildState + per-request-render mirror: every call below is the function
260
- // the daemon calls, in the daemon's order [LAW:single-enforcer]. Returns the
261
- // rendered line; appends the register pass's advisory `loadWarnings` (partial
262
- // declaration failures) to `warnings` — the same channel RenderCache merges
263
- // them into.
264
- function loadRegisterRender(
265
- configPath: string | null,
266
- cwd: string,
267
- warnings: string[],
268
- ): string {
269
- const { config: merged, source } = loadConfig(configPath, DEFAULT_DSL_CONFIG);
270
- const config = validateConfig(merged, configPath ?? "<default>", source);
271
-
272
- const store = new VariableStore();
273
- const registry = new SourceRegistry(
274
- store,
275
- config.globals.default_empty_value ?? "",
276
- undefined,
277
- new SessionState(),
278
- );
279
- try {
280
- const compiled = registerDslConfig(config, registry, { cwd });
281
- // Registered before the validator pass so a derive throw (a key-kind
282
- // clash) still carries the partial-load warnings into the fatal outcome.
283
- warnings.push(...compiled.loadWarnings);
284
- // Derivation only (the throw-on-clash coherence pass over the action
285
- // table); the daemon additionally registers the results in its global
286
- // validator registry, which a one-shot check has no wire to serve.
287
- deriveActionValidators(config);
288
-
289
- // Fresh session (no clicked theme/style/look), so the session half of
290
- // each resolution is null — the config default over the floor, exactly
291
- // what the daemon renders for a session that has never clicked.
292
- // [LAW:one-source-of-truth] The preset resolves first and its fragment's
293
- // globals feed every field below, the SAME order the daemon resolves in
294
- // (server.ts) — so `check` renders the arrangement a fresh session actually
295
- // opens in, not the config's un-presetted root.
296
- const effective: EffectiveGlobals = resolveEffectiveGlobals(
297
- config,
298
- // A fresh session: no clicked theme/style/look, and edit mode off. The
299
- // resolution is THE daemon's (resolveEffectiveGlobals), not a copy that
300
- // agrees with it today — which is the whole reason check renders what the
301
- // daemon would render rather than something adjacent.
302
- () => null,
303
- // [LAW:no-silent-failure] `check` validates a config file in isolation
304
- // — it never reads the daemon-owned overrides file, so there is no
305
- // rootOps log to be customized BY. false is the honest value for THIS
306
- // (primary, returned) render, not a stand-in default: a fresh session
307
- // has never customized anything. A second render pass below also
308
- // exercises `true`, so a `.preset.customized`-gated segment still
309
- // gets checked — just not through this value.
310
- () => false,
311
- );
312
- // [LAW:no-silent-failure] A segment whose template THROWS while evaluating
313
- // (an `{{ action }}` display-arity mismatch, a MissingFieldError from a
314
- // partially-declared variable) renders as a visible ⚠ error cell — partial
315
- // rendering, the daemon's channel for a human looking at the bar. The blind
316
- // authoring agent is not looking at the bar; check collects the same errors
317
- // through the render's observer seam and fails the verdict, so exit 0 never
318
- // blesses a bar that renders ⚠.
319
- const renderOnce = (
320
- payloadEffective: EffectiveGlobals,
321
- ): { rendered: string; segmentErrors: Map<string, string> } => {
322
- // [LAW:types-are-the-program] Keyed by segment NAME, not appended to a
323
- // list — a segment errors at most once per pass, so this is the
324
- // strongest true shape (dedupe-by-construction within one pass) and
325
- // what makes deduping ACROSS the two passes below a plain key check
326
- // rather than a message-text comparison.
327
- const segmentErrors = new Map<string, string>();
328
- const rendered = renderDsl(
329
- config,
330
- compiled,
331
- store,
332
- registry,
333
- checkPayload(payloadEffective),
334
- paletteForThemeName(payloadEffective.theme),
335
- {
336
- style: payloadEffective.style,
337
- separator: payloadEffective.separator,
338
- width: CHECK_WIDTH,
339
- colorCompatibility: payloadEffective.colorCompatibility,
340
- wrap: payloadEffective.autoWrap,
341
- padding: payloadEffective.padding,
342
- charset: payloadEffective.charset,
343
- },
344
- {
345
- onSegmentError: (segName, message) =>
346
- segmentErrors.set(segName, message),
347
- },
348
- {
349
- look: lookKeyByName(config.looks, payloadEffective.look),
350
- preset: payloadEffective.preset,
351
- },
352
- );
353
- return { rendered, segmentErrors };
354
- };
355
-
356
- const primary = renderOnce(effective);
357
- // [LAW:verifiable-goals] `.preset.customized` is the ONE gate this
358
- // config surface adds that a rich, data-driven fixture (checkPayload's
359
- // own stated design one comment up) can never drive true on its own —
360
- // every OTHER field a segment might gate on is a VALUE checkPayload can
361
- // just supply richly; this one is a daemon-resolved FACT about session
362
- // state, not a hookData field a config author's own file ever carries.
363
- // Without a second pass, a typo or MissingFieldError inside a user's
364
- // OWN `when: '{{ .preset.customized }}'`-gated content (docs/
365
- // interaction-authoring.md's own documented pattern) would pass check
366
- // clean and only surface later as a live ⚠ error cell. Second pass
367
- // only — the RETURNED rendering stays the realistic default (a fresh
368
- // session has never customized anything); this pass exists purely to
369
- // catch broken content behind the one gate the first pass can't reach.
370
- const customizedCheck = renderOnce({
371
- ...effective,
372
- presetCustomized: true,
373
- });
374
-
375
- // [LAW:no-silent-failure] An UNCONDITIONAL segment error (one whose
376
- // `when`, if any, is true in both passes — the two renders share the
377
- // same config/store/registry and differ only in `presetCustomized`)
378
- // fires in BOTH passes identically. Deduped by segment NAME rather than
379
- // concatenated: a customizedCheck error is only genuinely NEW
380
- // information when primary didn't already report that same segment —
381
- // reporting it twice would double-count one bug and the "(under
382
- // .preset.customized = true)" tag would misdirect the reader into
383
- // thinking it's specific to that gate when it isn't.
384
- const errors = [
385
- ...[...primary.segmentErrors].map(
386
- ([segName, message]) => `segment "${segName}": ${message}`,
387
- ),
388
- ...[...customizedCheck.segmentErrors]
389
- .filter(([segName]) => !primary.segmentErrors.has(segName))
390
- .map(
391
- ([segName, message]) =>
392
- `segment "${segName}": ${message} (under .preset.customized = true)`,
393
- ),
394
- ];
395
- if (errors.length > 0) {
396
- throw new Error(
397
- `config renders with ${errors.length} segment error${
398
- errors.length === 1 ? "" : "s"
399
- } (the daemon would render ⚠ error cells):\n` +
400
- errors.map((m) => ` ${m}`).join("\n"),
401
- );
402
- }
403
- return primary.rendered;
404
- } finally {
405
- // [LAW:single-enforcer] The registry owns every async handle the config
406
- // declared (timers, fs watchers, git subscriptions); a one-shot check must
407
- // not leak them past the verdict.
408
- registry.dispose();
409
- }
410
- }
411
-
412
- const EXIT_CLEAN = 0;
413
- const EXIT_FATAL = 1;
414
- const EXIT_USAGE = 2;
415
-
416
- // [LAW:dataflow-not-control-flow] The outcome → (streams, exit-code) mapping is
417
- // DATA: a total fold over CheckOutcome returning one descriptor; runCheck runs
418
- // the two unconditional writes + exit against it. Verdict on stdout, every
419
- // diagnostic (warnings included) on stderr — so `check` in a pipeline yields a
420
- // parseable verdict while a human still sees the advisories.
421
- export interface CheckPlan {
422
- readonly stdout: string;
423
- readonly stderr: string;
424
- readonly code: number;
425
- }
426
-
427
- function warningLines(warnings: readonly string[]): string {
428
- return warnings.map((w) => `warning: ${w}\n`).join("");
429
- }
430
-
431
- export function checkPlan(o: CheckOutcome): CheckPlan {
432
- switch (o.kind) {
433
- case "clean": {
434
- const where = o.configPath ?? "bundled default (no config file found)";
435
- const count =
436
- o.warnings.length > 0
437
- ? ` (${o.warnings.length} warning${o.warnings.length === 1 ? "" : "s"})`
438
- : "";
439
- return {
440
- stdout: `✓ ${where}: config OK${count}\n`,
441
- stderr: warningLines(o.warnings),
442
- code: EXIT_CLEAN,
443
- };
444
- }
445
- case "fatal":
446
- return {
447
- stdout: "",
448
- stderr:
449
- warningLines(o.warnings) +
450
- `✗ ${o.configPath ?? "<default>"}\n${o.message}\n`,
451
- code: EXIT_FATAL,
452
- };
453
- case "unreadable":
454
- return {
455
- stdout: "",
456
- stderr: `check: cannot read ${o.path}: ${o.message}\n`,
457
- code: EXIT_USAGE,
458
- };
459
- }
460
- }
461
-
462
- // `cc-candybar check [path]` — the argv binding. Extra arguments and an empty
463
- // path argument are usage errors (loud, not silently ignored — the likeliest
464
- // cause is an unquoted or mis-expanded shell variable). An empty string is not
465
- // "no argument": `checkConfig(undefined)` means "resolve like the daemon",
466
- // while `""` is a malformed target that would otherwise EISDIR on the cwd.
467
- export function runCheck(args: readonly string[]): never {
468
- if (args.length > 1 || args[0] === "") {
469
- process.stderr.write(
470
- "check: expected at most one non-empty path\nUsage: cc-candybar check [config-file]\n",
471
- );
472
- process.exit(EXIT_USAGE);
473
- }
474
- const plan = checkPlan(checkConfig(args[0]));
475
- process.stdout.write(plan.stdout);
476
- process.stderr.write(plan.stderr);
477
- process.exit(plan.code);
478
- }
package/src/cli-flags.ts DELETED
@@ -1,8 +0,0 @@
1
- // [LAW:one-source-of-truth] The bare flags Node answers itself. The dispatch in
2
- // index.ts reads it, `--help` renders its own lines from it, and the Rust client
3
- // routes exactly these to Node (its NODE_FLAGS; check-protocol diffs the two),
4
- // so a spelling added here without its mirror fails the build, not the user.
5
- export const NODE_FLAGS = {
6
- help: ["-h", "--help"],
7
- version: ["-V", "--version"],
8
- } as const;
package/src/click/wire.ts DELETED
@@ -1,158 +0,0 @@
1
- // [LAW:single-enforcer] THE click-wire codec. A click is an ordered list of
2
- // effects; this module is the one place that serializes that list to a URL and
3
- // parses it back. The renderer (every click emitter) calls effectsUrl; the
4
- // daemon's `dispatch` verb calls parseEffects. Encode and decode live together
5
- // so the format cannot drift between the two halves [LAW:one-source-of-truth].
6
- //
7
- // [LAW:dataflow-not-control-flow] N effects ride one URL the SAME way for N=1 and
8
- // N=100 — a lone click is the degenerate one-element list. There is no
9
- // plain-vs-compound mode: every URL effectsUrl emits is `dispatch/e=…`, and the
10
- // effect COUNT is data the dispatcher folds over, never a branch that selects a
11
- // wire. (The wire still ACCEPTS direct `cc-candybar://<verb>/…` URLs — old
12
- // scrollback links, a hand-authored `link` template — so a direct verb is the
13
- // degenerate one-effect case on the parse side; only emission is unified here.)
14
- //
15
- // Why query params (not slashes, not base64): the value handed to the daemon is
16
- // passed RAW (parseHandlerUrl decodes only the verb), so each `e` param survives
17
- // exactly one URLSearchParams decode and an effect's own slash-bearing value
18
- // (a path, a set-state key/value tail) round-trips untouched. base64 was
19
- // rejected as opaque; a slash-nested payload is unsafe under any single
20
- // whole-value decode (a `%2F` would un-escape into a structural separator). The
21
- // `e=…&e=…` payload follows the verb after a `/` (`dispatch/e=…`), NOT a `?`, so
22
- // `/` stays the one verb delimiter and `?` remains ordinary data in a bare-copy
23
- // value (`cc-candybar://hello?world`).
24
-
25
- import { URLSearchParams } from "node:url";
26
-
27
- // [LAW:one-source-of-truth] The scheme string lives here, with the codec that
28
- // emits it; install/ (Launch Services registration) imports it.
29
- export const URL_SCHEME = "cc-candybar";
30
-
31
- // [LAW:one-source-of-truth] The verb vocabulary. The daemon's VERBS registry
32
- // keys off these and every emitter builds effects with them, so the emitted
33
- // verb and the dispatched handler cannot name-drift.
34
- export const VERB_DISPATCH = "dispatch";
35
- export const VERB_SET_STATE = "set-state";
36
- // [LAW:types-are-the-program] A RELATIVE state nudge: its args are
37
- // `[sessionId, key, by]` where `by` is the signed integer delta. Distinct from
38
- // set-state because the click intent is "step from whatever the value IS now",
39
- // not "set to this fixed value" — the absolute target is computed at APPLY time
40
- // from live state, so the link carries no `current` snapshot and N rapid clicks
41
- // each re-read-and-write. Additive: old set-state links still resolve.
42
- export const VERB_STEP_STATE = "step-state";
43
- export const VERB_COPY = "copy";
44
- export const VERB_OPEN_VSCODE = "open-vscode";
45
- export const VERB_TOOLBAR_TOGGLE = "toolbar-toggle";
46
- export const VERB_SHOW_CONFIG_ERROR = "show-config-error";
47
- export const VERB_SHOW_CONFIG_WARNING = "show-config-warning";
48
- // [LAW:effects-at-boundaries] A daemon-global config override: the verb writes
49
- // the override path (or clears it with an empty value); the render pipeline
50
- // reads it at the cache-lookup boundary. Clicking a different config is a
51
- // side-effect isolated to the verb handler; the renderer only sees the result.
52
- export const VERB_LOAD_CONFIG = "load-config";
53
- // [LAW:one-source-of-truth] `persist`'s twin of set-state/step-state: writes
54
- // land in the daemon-owned config-overrides layer (never the hand-authored
55
- // config file), which RenderCache merges on top of the user file every
56
- // reload — the SAME file-watcher path a hand edit already takes
57
- // (candybar-config-engine-71o.2). Args: `[sessionId, key, value]` — the
58
- // sessionId is carried only for click.error surfacing, exactly like
59
- // set-state; the write itself is daemon-global, not session-scoped.
60
- // A durable write takes an OPTIONAL trailing segment: the SessionState key to
61
- // RELEASE once the write has succeeded. A dual-destination control
62
- // (candybar-settings-ui-aok.3) commits "make this the durable default AND stop
63
- // overriding it in this session" — one intent, whose session half must not
64
- // happen if the durable half failed. Carried as one more segment on the write
65
- // itself rather than as a second effect beside it, because `dispatch` runs
66
- // every effect in a click by design; a pair would let a rejected write still
67
- // wipe the user's pick. Args: `[sessionId, key, value, releaseKey?]`.
68
- export const VERB_SET_CONFIG = "set-config";
69
- // [LAW:types-are-the-program] A RELATIVE nudge to a bounded config-overrides
70
- // key (e.g. a padding stepper) — the config twin of step-state. Args:
71
- // `[sessionId, key, by, releaseKey?]` — the same optional release segment
72
- // set-config takes, for the same reason.
73
- export const VERB_STEP_CONFIG = "step-config";
74
- // [LAW:one-source-of-truth] The gated undo for `persist`: clears one
75
- // config-overrides key, restoring the user-file/bundled-default value on the
76
- // next reload. Args: `[sessionId, key]`.
77
- export const VERB_RESET_CONFIG = "reset-config";
78
- // [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's structural-edit
79
- // verb — a THIRD write semantic beside set-config's plain overwrite and
80
- // step-config's numeric read-modify-write: read the current op-token LIST at
81
- // `key` (a "presets.<name>.rootOps" config-overrides target), append the
82
- // validated `op` token, write the whole list back. Args: `[sessionId, key,
83
- // op]` — `op` is one opaque token from src/config/layout-ops.ts's codec, the
84
- // SAME shape a `persist … to` literal's value would be, gated the SAME way
85
- // (validateConfigWrite) — only the write's SHAPE (append vs. overwrite)
86
- // differs, which is exactly why this is its own verb rather than another
87
- // VERB_SET_CONFIG value.
88
- export const VERB_APPLY_LAYOUT_OP = "apply-layout-op";
89
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history step
90
- // over the config-overrides layer — the fine-grained sibling of
91
- // VERB_RESET_CONFIG's coarse "clear one key". Args: `[sessionId]` — there is
92
- // no key: the history is ONE stack over every persist/reset write ever made
93
- // to the overrides file (config-overrides-store.ts), not a per-key log. An
94
- // empty stack is a loud BAD_REQUEST surfaced through click.error like any
95
- // other verb failure, never a silent no-op.
96
- export const VERB_UNDO = "undo";
97
- export const VERB_REDO = "redo";
98
-
99
- // [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
100
- // positional args. The wire owns all encoding — callers never percent-encode.
101
- // set-state's args are `[sessionId, key, value, …]`; copy/open carry one arg.
102
- export interface Effect {
103
- readonly verb: string;
104
- readonly args: readonly string[];
105
- }
106
-
107
- // [LAW:types-are-the-program] A parsed effect as the dispatcher sees it: the verb
108
- // and the still-encoded segment tail. The tail stays encoded because the target
109
- // verb's handler decodes its own segments at its boundary (single-enforcer per
110
- // verb) — the same contract a direct (non-dispatch) click URL hands a handler.
111
- export interface ParsedEffect {
112
- readonly verb: string;
113
- readonly value: string;
114
- }
115
-
116
- // [LAW:single-enforcer] The segment codec. A verb's args serialize to a
117
- // slash-joined run of percent-encoded segments; the handler decodes the inverse.
118
- // Encoding each segment means a segment's own `/` becomes `%2F` and never reads
119
- // as a separator — the slash-safety the old whole-value decode could not give.
120
- export function encodeSegments(parts: readonly string[]): string {
121
- return parts.map(encodeURIComponent).join("/");
122
- }
123
-
124
- export function decodeSegments(value: string): string[] {
125
- return value.length === 0 ? [] : value.split("/").map(decodeURIComponent);
126
- }
127
-
128
- // Serialize an effect list to its dispatch URL. Each effect becomes one ordered
129
- // `e` query param carrying `verb/<encoded-args>`, percent-encoded whole so its
130
- // internal `/`, `&`, `=` survive as data. The payload follows `dispatch/` (not
131
- // `dispatch?`) so `/` is the only verb delimiter parseHandlerUrl needs.
132
- export function effectsUrl(effects: readonly Effect[]): string {
133
- const qs = effects
134
- .map(
135
- (e) => `e=${encodeURIComponent(`${e.verb}/${encodeSegments(e.args)}`)}`,
136
- )
137
- .join("&");
138
- return `${URL_SCHEME}://${VERB_DISPATCH}/${qs}`;
139
- }
140
-
141
- // [LAW:dataflow-not-control-flow] Parse the dispatch verb's raw value (an
142
- // `e=…&e=…` query string) into the ordered effect list. URLSearchParams decodes
143
- // each param exactly once and preserves insertion order; splitting each on the
144
- // FIRST `/` recovers (verb, still-encoded tail) — the same split parseHandlerUrl
145
- // applies at the top level, one level down.
146
- export function parseEffects(rawValue: string): ParsedEffect[] {
147
- return new URLSearchParams(rawValue).getAll("e").map(splitVerb);
148
- }
149
-
150
- // [LAW:types-are-the-program] Split a `verb/tail` string at the first `/`. A
151
- // verb with no args (no slash) yields an empty tail — the degenerate case, not a
152
- // guard. The tail keeps its slashes (further segments) for the handler to decode.
153
- export function splitVerb(s: string): ParsedEffect {
154
- const i = s.indexOf("/");
155
- return i === -1
156
- ? { verb: s, value: "" }
157
- : { verb: s.slice(0, i), value: s.slice(i + 1) };
158
- }