@promptctl/cc-candybar 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.19.0",
3
+ "version": "1.21.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",
@@ -95,10 +95,10 @@
95
95
  "mobx": "^6.15.0"
96
96
  },
97
97
  "optionalDependencies": {
98
- "@promptctl/cc-candybar-darwin-arm64": "1.19.0",
99
- "@promptctl/cc-candybar-darwin-x64": "1.19.0",
100
- "@promptctl/cc-candybar-linux-x64": "1.19.0",
101
- "@promptctl/cc-candybar-linux-arm64": "1.19.0"
98
+ "@promptctl/cc-candybar-darwin-arm64": "1.21.0",
99
+ "@promptctl/cc-candybar-darwin-x64": "1.21.0",
100
+ "@promptctl/cc-candybar-linux-x64": "1.21.0",
101
+ "@promptctl/cc-candybar-linux-arm64": "1.21.0"
102
102
  },
103
103
  "pnpm": {
104
104
  "supportedArchitectures": {
@@ -26,6 +26,9 @@
26
26
  "palette": {
27
27
  "type": "string"
28
28
  },
29
+ "look": {
30
+ "type": "string"
31
+ },
29
32
  "style": {
30
33
  "enum": [
31
34
  "powerline",
@@ -1140,7 +1143,8 @@
1140
1143
  "from": {
1141
1144
  "enum": [
1142
1145
  "themes",
1143
- "styles"
1146
+ "styles",
1147
+ "looks"
1144
1148
  ]
1145
1149
  }
1146
1150
  },
@@ -1239,6 +1243,28 @@
1239
1243
  ]
1240
1244
  }
1241
1245
  },
1246
+ "looks": {
1247
+ "type": "object",
1248
+ "additionalProperties": {
1249
+ "type": "object",
1250
+ "properties": {
1251
+ "hueShift": {
1252
+ "type": "number"
1253
+ },
1254
+ "chromaScale": {
1255
+ "type": "number",
1256
+ "minimum": 0
1257
+ },
1258
+ "lightnessScale": {
1259
+ "type": "number"
1260
+ },
1261
+ "lightnessShift": {
1262
+ "type": "number"
1263
+ }
1264
+ },
1265
+ "additionalProperties": false
1266
+ }
1267
+ },
1242
1268
  "helpers": {
1243
1269
  "type": "object",
1244
1270
  "additionalProperties": {
package/src/check.ts ADDED
@@ -0,0 +1,398 @@
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 { VariableStore } from "./var-system/store.js";
29
+ import { SourceRegistry } from "./var-system/sources.js";
30
+ import { SessionState } from "./daemon/session-state.js";
31
+ import { registerDslConfig, renderDsl } from "./dsl/render.js";
32
+ import { deriveActionValidators } from "./daemon/verbs/state-validators.js";
33
+ import {
34
+ effectiveThemeName,
35
+ effectiveLookName,
36
+ lookKeyByName,
37
+ effectiveStripStyle,
38
+ } from "./themes/policy.js";
39
+ import { resolverForThemeName } from "./themes/palette-resolvers.js";
40
+ import {
41
+ DEFAULT_CHARSET,
42
+ DEFAULT_COLOR_COMPATIBILITY,
43
+ DEFAULT_PADDING,
44
+ DEFAULT_WRAP,
45
+ } from "./render/strip.js";
46
+
47
+ // [LAW:no-ambient-temporal-coupling] A fixed width keeps the verdict a function
48
+ // of the config alone, not of whichever terminal invoked the check. Templates
49
+ // evaluate in full before any width-driven wrap/pagination, so width shapes
50
+ // layout, never diagnostics.
51
+ const CHECK_WIDTH = 200;
52
+
53
+ // One faked Claude Code hook event, shaped like the daemon's augmented payload
54
+ // (see src/daemon/render-payload.ts) — the `input` vars read out of it by their
55
+ // dotted `path`. [LAW:verifiable-goals] It is deliberately RICH (dirty git with
56
+ // every worktree count, an upstream, a stash, a recent commit; home set; live
57
+ // session/today/context/metrics/rate-limit data) so gated segments actually
58
+ // RENDER their content instead of gating off. A minimal payload would let a
59
+ // field-name typo in the git/directory/metrics/budget branches slip through —
60
+ // those branches only run when their data is present.
61
+ //
62
+ // `effectiveTheme` is threaded in exactly as the daemon threads it (server.ts
63
+ // resolves effectiveThemeName once and feeds BOTH the payload's
64
+ // `theme.effective` field and the basePalette below) [LAW:one-source-of-truth].
65
+ //
66
+ // test/example-configs.test.ts asserts rendered content against these literal
67
+ // values (780s → "◷ 13m", cost $0.39, version 1.15.0, …); changing one here
68
+ // fails that suite loudly rather than drifting silently.
69
+ export function checkPayload(
70
+ effectiveTheme: string,
71
+ effectiveLook: string,
72
+ ): Record<string, unknown> {
73
+ const home = "/home/tester";
74
+ const nowSec = Math.floor(Date.now() / 1000);
75
+ return {
76
+ hook_event_name: "Status",
77
+ session_id: "test0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b",
78
+ version: "1.15.0",
79
+ home,
80
+ cwd: `${home}/code/cc-candybar/src`,
81
+ transcript_path: `${home}/.claude/projects/x/test.jsonl`,
82
+ model: { id: "claude-opus-4-8", display_name: "Opus 4.8" },
83
+ workspace: {
84
+ current_dir: `${home}/code/cc-candybar/src`,
85
+ project_dir: `${home}/code/cc-candybar`,
86
+ },
87
+ git: {
88
+ repoName: "cc-candybar",
89
+ branch: "main",
90
+ sha: "abc1234",
91
+ ahead: 2,
92
+ behind: 1,
93
+ staged: 3,
94
+ unstaged: 2,
95
+ untracked: 1,
96
+ conflicts: 0,
97
+ upstream: "origin/main",
98
+ stash: 1,
99
+ status: "dirty",
100
+ operation: "rebase",
101
+ timeSinceCommit: 780,
102
+ },
103
+ session: { cost: 0.39, tokens: 241400 },
104
+ today: { cost: 12.5, tokens: 3_400_000 },
105
+ context: { totalTokens: 48487, contextLeft: 24 },
106
+ metrics: {
107
+ lastResponseTime: 8.2,
108
+ responseTime: 4.2,
109
+ sessionDuration: 930,
110
+ messageCount: 8,
111
+ linesAdded: 512,
112
+ linesRemoved: 88,
113
+ },
114
+ block: { nativeUtilization: 63, resetsAt: nowSec + 2 * 3600 },
115
+ weekly: { percentage: 21, resetsAt: nowSec + 5 * 86400 },
116
+ cache: { expiresAt: nowSec + 15 * 60 },
117
+ tmux: { session: "work" },
118
+ theme: { effective: effectiveTheme },
119
+ look: { effective: effectiveLook },
120
+ };
121
+ }
122
+
123
+ // [LAW:dataflow-not-control-flow] The check result is DATA — a pure function of
124
+ // the target file's contents — discriminated into the three outcomes the exit-
125
+ // code contract projects. `checkConfig` carries the decision; `runCheck` only
126
+ // maps it to (streams, exit), so the contract is testable without spawning a
127
+ // process or stubbing process.exit.
128
+ //
129
+ // `configPath` null means the bundled default was checked (no config file
130
+ // found — the daemon renders the same default in that state).
131
+ export type CheckOutcome =
132
+ | {
133
+ readonly kind: "clean";
134
+ readonly configPath: string | null;
135
+ readonly warnings: readonly string[];
136
+ readonly rendered: string;
137
+ }
138
+ | {
139
+ readonly kind: "fatal";
140
+ readonly configPath: string | null;
141
+ readonly message: string;
142
+ readonly warnings: readonly string[];
143
+ }
144
+ | {
145
+ readonly kind: "unreadable";
146
+ readonly path: string;
147
+ readonly message: string;
148
+ };
149
+
150
+ // Run the daemon's load-and-render pipeline against one config target.
151
+ //
152
+ // With no target, the path resolves exactly as the daemon resolves it
153
+ // (resolveDslConfigPath: $CC_CANDYBAR_CONFIG → project/cwd → XDG), so the file
154
+ // this checks IS the file the daemon would load from this directory.
155
+ //
156
+ // [LAW:no-silent-failure] With an explicit target, the named file must exist
157
+ // and be readable — a missing file is `unreadable`, never a fall-through to the
158
+ // bundled default. (The daemon's --config-to-missing-file behavior — render the
159
+ // default, watch for the file to appear — is liveness for a long-running
160
+ // renderer; a verdict command must not report "clean" about a file it never
161
+ // read.)
162
+ export function checkConfig(
163
+ target: string | undefined,
164
+ cwd: string = process.cwd(),
165
+ ): CheckOutcome {
166
+ // [LAW:one-source-of-truth] No pre-read: the ONE content read of the config
167
+ // file is the readFileSync inside loadConfig. Readability is established by
168
+ // the same read that parses (no double I/O); the catch below classifies its
169
+ // errno failure as `unreadable`. The explicit-target statSync is a metadata
170
+ // probe at the argv trust boundary, not a second read: a directory target
171
+ // (`check .`) fails read() with a path-less EISDIR the catch could not
172
+ // attribute, so the not-a-file usage error is decided here.
173
+ const configPath =
174
+ target !== undefined
175
+ ? path.resolve(expandHome(target))
176
+ : resolveDslConfigPath(cwd, cwd);
177
+ if (target !== undefined && configPath !== null) {
178
+ // throwIfNoEntry suppresses only ENOENT (left for the content read to
179
+ // classify); EACCES/EPERM on the probe itself is equally "could not read
180
+ // the named file" — same outcome, not an uncaught stack.
181
+ let st: fs.Stats | undefined;
182
+ try {
183
+ st = fs.statSync(configPath, { throwIfNoEntry: false });
184
+ } catch (e) {
185
+ return {
186
+ kind: "unreadable",
187
+ path: configPath,
188
+ message: e instanceof Error ? e.message : String(e),
189
+ };
190
+ }
191
+ if (st !== undefined && !st.isFile()) {
192
+ return {
193
+ kind: "unreadable",
194
+ path: configPath,
195
+ message: "not a file",
196
+ };
197
+ }
198
+ }
199
+
200
+ // [LAW:dataflow-not-control-flow] Collision detection runs independent of
201
+ // load success — mirror of RenderCache.reloadInto: even if the .json5 fails
202
+ // to parse, the author still wants to know a shadowed .json sibling exists.
203
+ const warnings: string[] = [];
204
+ const collision = detectConfigCollisions(cwd, cwd);
205
+ if (collision !== null) warnings.push(collision);
206
+
207
+ try {
208
+ const rendered = loadRegisterRender(configPath, cwd, warnings);
209
+ return { kind: "clean", configPath, warnings, rendered };
210
+ } catch (e) {
211
+ // A filesystem error on the config file itself (ENOENT/EACCES from
212
+ // loadConfig's read — errno errors carry the failing `.path`, which is the
213
+ // discriminator against deeper fs failures) is the `unreadable` outcome:
214
+ // the named file could not be read at all, distinct from a file that read
215
+ // but is invalid. [LAW:no-silent-failure] — never a fall-through to the
216
+ // bundled default. Duck-typed, not `instanceof Error`: fs errors can cross
217
+ // a realm boundary (jest/graceful-fs), where instanceof lies.
218
+ const errno = e as Partial<NodeJS.ErrnoException> | null;
219
+ if (
220
+ configPath !== null &&
221
+ typeof errno === "object" &&
222
+ errno !== null &&
223
+ typeof errno.code === "string" &&
224
+ errno.path === configPath &&
225
+ typeof errno.message === "string"
226
+ ) {
227
+ return { kind: "unreadable", path: configPath, message: errno.message };
228
+ }
229
+ // Same classification RenderCache.reloadInto applies: ConfigError and
230
+ // register/render throws (template parse, MissingFieldError, action arity)
231
+ // are all author-facing diagnostics — the daemon would surface each via
232
+ // composeWithDiagnostics, so check surfaces each as fatal text.
233
+ const message =
234
+ e instanceof ConfigError
235
+ ? e.message
236
+ : e instanceof Error
237
+ ? e.message
238
+ : String(e);
239
+ return { kind: "fatal", configPath, message, warnings };
240
+ }
241
+ }
242
+
243
+ // The buildState + per-request-render mirror: every call below is the function
244
+ // the daemon calls, in the daemon's order [LAW:single-enforcer]. Returns the
245
+ // rendered line; appends the register pass's advisory `loadWarnings` (partial
246
+ // declaration failures) to `warnings` — the same channel RenderCache merges
247
+ // them into.
248
+ function loadRegisterRender(
249
+ configPath: string | null,
250
+ cwd: string,
251
+ warnings: string[],
252
+ ): string {
253
+ const { config: merged, source } = loadConfig(configPath);
254
+ const config = validateConfig(merged, configPath ?? "<default>", source);
255
+
256
+ const store = new VariableStore();
257
+ const registry = new SourceRegistry(
258
+ store,
259
+ config.globals.default_empty_value ?? "",
260
+ undefined,
261
+ new SessionState(),
262
+ );
263
+ try {
264
+ const compiled = registerDslConfig(config, registry, { cwd });
265
+ // Registered before the validator pass so a derive throw (a key-kind
266
+ // clash) still carries the partial-load warnings into the fatal outcome.
267
+ warnings.push(...compiled.loadWarnings);
268
+ // Derivation only (the throw-on-clash coherence pass over the action
269
+ // table); the daemon additionally registers the results in its global
270
+ // validator registry, which a one-shot check has no wire to serve.
271
+ deriveActionValidators(config);
272
+
273
+ // Fresh session (no clicked theme/style), so the session half of each
274
+ // resolution is null — the config default over the floor, exactly what the
275
+ // daemon renders for a session that has never clicked.
276
+ const effectiveTheme = effectiveThemeName(null, config.globals.palette);
277
+ // Same fresh-session shape one dimension over: no clicked look, so the
278
+ // config default over the "none" floor — exactly what the daemon renders
279
+ // for a session that has never clicked.
280
+ const effectiveLook = effectiveLookName(
281
+ null,
282
+ config.globals.look,
283
+ config.looks,
284
+ );
285
+ // [LAW:no-silent-failure] A segment whose template THROWS while evaluating
286
+ // (an `{{ action }}` display-arity mismatch, a MissingFieldError from a
287
+ // partially-declared variable) renders as a visible ⚠ error cell — partial
288
+ // rendering, the daemon's channel for a human looking at the bar. The blind
289
+ // authoring agent is not looking at the bar; check collects the same errors
290
+ // through the render's observer seam and fails the verdict, so exit 0 never
291
+ // blesses a bar that renders ⚠.
292
+ const segmentErrors: string[] = [];
293
+ const rendered = renderDsl(
294
+ config,
295
+ compiled,
296
+ store,
297
+ registry,
298
+ checkPayload(effectiveTheme, effectiveLook),
299
+ resolverForThemeName(effectiveTheme),
300
+ {
301
+ style: effectiveStripStyle(null, config.globals.style),
302
+ width: CHECK_WIDTH,
303
+ colorCompatibility:
304
+ config.globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
305
+ wrap: config.globals.autoWrap ?? DEFAULT_WRAP,
306
+ padding: config.globals.padding ?? DEFAULT_PADDING,
307
+ charset: config.globals.charset ?? DEFAULT_CHARSET,
308
+ },
309
+ {
310
+ onSegmentError: (segName, message) =>
311
+ segmentErrors.push(`segment "${segName}": ${message}`),
312
+ },
313
+ lookKeyByName(config.looks, effectiveLook),
314
+ );
315
+ if (segmentErrors.length > 0) {
316
+ throw new Error(
317
+ `config renders with ${segmentErrors.length} segment error${
318
+ segmentErrors.length === 1 ? "" : "s"
319
+ } (the daemon would render ⚠ error cells):\n` +
320
+ segmentErrors.map((m) => ` ${m}`).join("\n"),
321
+ );
322
+ }
323
+ return rendered;
324
+ } finally {
325
+ // [LAW:single-enforcer] The registry owns every async handle the config
326
+ // declared (timers, fs watchers, git subscriptions); a one-shot check must
327
+ // not leak them past the verdict.
328
+ registry.dispose();
329
+ }
330
+ }
331
+
332
+ const EXIT_CLEAN = 0;
333
+ const EXIT_FATAL = 1;
334
+ const EXIT_USAGE = 2;
335
+
336
+ // [LAW:dataflow-not-control-flow] The outcome → (streams, exit-code) mapping is
337
+ // DATA: a total fold over CheckOutcome returning one descriptor; runCheck runs
338
+ // the two unconditional writes + exit against it. Verdict on stdout, every
339
+ // diagnostic (warnings included) on stderr — so `check` in a pipeline yields a
340
+ // parseable verdict while a human still sees the advisories.
341
+ export interface CheckPlan {
342
+ readonly stdout: string;
343
+ readonly stderr: string;
344
+ readonly code: number;
345
+ }
346
+
347
+ function warningLines(warnings: readonly string[]): string {
348
+ return warnings.map((w) => `warning: ${w}\n`).join("");
349
+ }
350
+
351
+ export function checkPlan(o: CheckOutcome): CheckPlan {
352
+ switch (o.kind) {
353
+ case "clean": {
354
+ const where = o.configPath ?? "bundled default (no config file found)";
355
+ const count =
356
+ o.warnings.length > 0
357
+ ? ` (${o.warnings.length} warning${o.warnings.length === 1 ? "" : "s"})`
358
+ : "";
359
+ return {
360
+ stdout: `✓ ${where}: config OK${count}\n`,
361
+ stderr: warningLines(o.warnings),
362
+ code: EXIT_CLEAN,
363
+ };
364
+ }
365
+ case "fatal":
366
+ return {
367
+ stdout: "",
368
+ stderr:
369
+ warningLines(o.warnings) +
370
+ `✗ ${o.configPath ?? "<default>"}\n${o.message}\n`,
371
+ code: EXIT_FATAL,
372
+ };
373
+ case "unreadable":
374
+ return {
375
+ stdout: "",
376
+ stderr: `check: cannot read ${o.path}: ${o.message}\n`,
377
+ code: EXIT_USAGE,
378
+ };
379
+ }
380
+ }
381
+
382
+ // `cc-candybar check [path]` — the argv binding. Extra arguments and an empty
383
+ // path argument are usage errors (loud, not silently ignored — the likeliest
384
+ // cause is an unquoted or mis-expanded shell variable). An empty string is not
385
+ // "no argument": `checkConfig(undefined)` means "resolve like the daemon",
386
+ // while `""` is a malformed target that would otherwise EISDIR on the cwd.
387
+ export function runCheck(args: readonly string[]): never {
388
+ if (args.length > 1 || args[0] === "") {
389
+ process.stderr.write(
390
+ "check: expected at most one non-empty path\nUsage: cc-candybar check [config-file]\n",
391
+ );
392
+ process.exit(EXIT_USAGE);
393
+ }
394
+ const plan = checkPlan(checkConfig(args[0]));
395
+ process.stdout.write(plan.stdout);
396
+ process.stderr.write(plan.stderr);
397
+ process.exit(plan.code);
398
+ }
@@ -25,11 +25,18 @@
25
25
  // discriminator the loader and the validator-derivation match on.
26
26
 
27
27
  // [LAW:one-source-of-truth] The domain lists a picker draws options from. Same
28
- // canonical sources the `themes()`/`styles()` bindings and the set-state
29
- // validators consult — the rendered options and the derived gate cannot diverge
30
- // because there is no second enumeration.
31
- export type OptionSource = "themes" | "styles";
32
- export const OPTION_SOURCES: readonly OptionSource[] = ["themes", "styles"];
28
+ // canonical sources the `themes()`/`styles()`/`looks()` bindings and the
29
+ // set-state validators consult — the rendered options and the derived gate
30
+ // cannot diverge because there is no second enumeration. themes/styles are
31
+ // static registry lists; "looks" is the one PER-CONFIG domain (the merged
32
+ // `looks` block's names), so its resolution sites take the config's look names
33
+ // as data rather than consulting a module constant.
34
+ export type OptionSource = "themes" | "styles" | "looks";
35
+ export const OPTION_SOURCES: readonly OptionSource[] = [
36
+ "themes",
37
+ "styles",
38
+ "looks",
39
+ ];
33
40
 
34
41
  // [LAW:types-are-the-program] The top-level discriminator of an ActionDecl — the
35
42
  // click effect is keyed by which of these is present. The loader proves
package/src/config/cli.ts CHANGED
@@ -1,128 +1,17 @@
1
- // [LAW:single-enforcer] Config-tooling CLI entry points. `lint` and `schema`
2
- // share one module because they share one change-reason: the config grammar.
3
- // Neither carries validation or schema LOGIC `lint` is a second entry point
4
- // into the loader (the single config-validation enforcer the daemon also uses),
5
- // and `schema` serves the build-generated artifact (derived from the config
6
- // types). Both are bindings, not reimplementations [LAW:one-source-of-truth].
1
+ // [LAW:single-enforcer] Config-tooling CLI entry point for `schema`. It carries
2
+ // no schema LOGIC it serves the build-generated artifact (derived from the
3
+ // loader's declarations), so it is a binding, not a reimplementation
4
+ // [LAW:one-source-of-truth]. Config *validation* lives in `cc-candybar check`
5
+ // (src/check.ts) the full-pipeline verdict command; the old `lint` is its
6
+ // alias.
7
7
 
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import process from "node:process";
11
- import { loadConfig, validateConfig } from "./dsl-loader.js";
12
- import { ConfigError } from "./loader/diagnostics.js";
13
11
 
14
- // Exit codes are a contract (the CLI guideline: not just 0/1), so scripts and
15
- // editors can distinguish "your config is wrong" from "I couldn't run":
16
- // 0 — config is valid
17
- // 1 — config is invalid (ConfigError: structural, cross-ref, or cycle)
18
- // 2 — usage error or the file could not be read
19
12
  const EXIT_VALID = 0;
20
- const EXIT_INVALID = 1;
21
13
  const EXIT_USAGE = 2;
22
14
 
23
- // [LAW:dataflow-not-control-flow] The lint result is DATA — a pure function of
24
- // the target file's contents — discriminated into the three outcomes the exit-
25
- // code contract projects. `lintConfig` carries the decision; `runLint` only maps
26
- // it to (stream, exit). The decision is unit-testable without spawning a process
27
- // or stubbing process.exit, which is what makes the exit-code goal verifiable.
28
- export type LintOutcome =
29
- | { readonly kind: "valid"; readonly path: string }
30
- | { readonly kind: "invalid"; readonly message: string }
31
- | {
32
- readonly kind: "unreadable";
33
- readonly path: string;
34
- readonly message: string;
35
- };
36
-
37
- // Run the real loader (parse → merge-with-default → cross-ref + cycle validation)
38
- // against an arbitrary file. No daemon: the loader imports only fs, JSON5, and
39
- // pure validators, so the same errors the daemon would surface at render time are
40
- // surfaced here ahead of time.
41
- //
42
- // [LAW:single-enforcer] loadConfig + validateConfig is the identical pipeline
43
- // RenderCache.reloadInto runs in the daemon — the validation authority is one
44
- // function, so lint cannot drift from production. The source we read is passed to
45
- // validateConfig only to sharpen line numbers (semantic issues map to lines).
46
- export function lintConfig(target: string): LintOutcome {
47
- const resolved = path.resolve(target);
48
-
49
- let source: string;
50
- try {
51
- source = fs.readFileSync(resolved, "utf-8");
52
- } catch (e) {
53
- return {
54
- kind: "unreadable",
55
- path: target,
56
- message: e instanceof Error ? e.message : String(e),
57
- };
58
- }
59
-
60
- try {
61
- const { config } = loadConfig(resolved);
62
- validateConfig(config, resolved, source);
63
- } catch (e) {
64
- if (e instanceof ConfigError)
65
- return { kind: "invalid", message: e.message };
66
- throw e;
67
- }
68
-
69
- return { kind: "valid", path: target };
70
- }
71
-
72
- // `cc-candybar lint <path>` — the argv binding. Missing-arg is a usage concern
73
- // (not a lint outcome), handled here; everything else is the projection of
74
- // lintConfig's outcome onto the stream + exit-code contract.
75
- export function runLint(args: readonly string[]): void {
76
- const target = args[0];
77
- if (target === undefined || target === "") {
78
- process.stderr.write(
79
- "lint: missing <path>\nUsage: cc-candybar lint <config-file>\n",
80
- );
81
- process.exit(EXIT_USAGE);
82
- }
83
- applyLintOutcome(lintConfig(target));
84
- }
85
-
86
- // [LAW:dataflow-not-control-flow] The outcome → (stream, text, exit-code) mapping
87
- // is DATA. `lintPlan` is a total fold returning that descriptor (a non-returning
88
- // arm fails the typecheck, so the projection stays exhaustive over LintOutcome);
89
- // `applyLintOutcome` runs the single write + exit against it. The side effects
90
- // are unconditional; the data decides their content.
91
- interface LintPlan {
92
- readonly stream: NodeJS.WriteStream;
93
- readonly text: string;
94
- readonly code: number;
95
- }
96
-
97
- function lintPlan(o: LintOutcome): LintPlan {
98
- switch (o.kind) {
99
- case "valid":
100
- return {
101
- stream: process.stdout,
102
- text: `✓ ${o.path}: config valid\n`,
103
- code: EXIT_VALID,
104
- };
105
- case "invalid":
106
- return {
107
- stream: process.stderr,
108
- text: o.message + "\n",
109
- code: EXIT_INVALID,
110
- };
111
- case "unreadable":
112
- return {
113
- stream: process.stderr,
114
- text: `lint: cannot read ${o.path}: ${o.message}\n`,
115
- code: EXIT_USAGE,
116
- };
117
- }
118
- }
119
-
120
- function applyLintOutcome(o: LintOutcome): never {
121
- const plan = lintPlan(o);
122
- plan.stream.write(plan.text);
123
- process.exit(plan.code);
124
- }
125
-
126
15
  // Read the build-generated JSON Schema for the config file shape (RawDslConfig),
127
16
  // or null when the artifact is absent. Pure read — `runSchema` owns the side
128
17
  // effects, so the locate-and-read path is testable.
@@ -137,7 +26,19 @@ export function loadSchemaText(): string | null {
137
26
  // time (scripts/gen-schema.ts → emitConfigSchema); served verbatim here (the
138
27
  // emitter runs at build, not ship time).
139
28
  export function runSchema(): void {
140
- const text = loadSchemaText();
29
+ // [LAW:no-silent-failure] A schema that exists but cannot be read (EACCES, a
30
+ // deletion racing the existsSync in locateSchema) is a distinct failure from
31
+ // schema-not-found — report it as what it is, not a misleading top-level
32
+ // render error.
33
+ let text: string | null;
34
+ try {
35
+ text = loadSchemaText();
36
+ } catch (e) {
37
+ process.stderr.write(
38
+ `schema: cannot read bundled schema: ${e instanceof Error ? e.message : String(e)}\n`,
39
+ );
40
+ process.exit(EXIT_USAGE);
41
+ }
141
42
  if (text === null) {
142
43
  process.stderr.write(
143
44
  "schema: bundled schema not found (expected schema/cc-candybar.schema.json). " +