@promptctl/cc-candybar 1.18.1 → 1.20.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/dist/index.mjs +41 -39
- package/package.json +6 -6
- package/src/check.ts +359 -0
- package/src/config/cli.ts +19 -118
- package/src/config/default-dsl-config.ts +16 -19
- package/src/config/disclosure.ts +55 -0
- package/src/config/loader/layout.ts +34 -32
- package/src/config/loader/menu-synth.ts +163 -108
- package/src/config/loader/refs.ts +11 -10
- package/src/config/loader/reserved-namespace.ts +38 -0
- package/src/config/menu-keys.ts +78 -11
- package/src/daemon/verbs/state-validators.ts +41 -44
- package/src/index.ts +12 -5
- package/src/render/action.ts +7 -1
- package/src/render/menu.ts +49 -56
- package/src/render/picker.ts +51 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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",
|
|
@@ -89,16 +89,16 @@
|
|
|
89
89
|
"typescript": "^5.0.0"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
-
"@promptctl/go-template-js": "^0.
|
|
92
|
+
"@promptctl/go-template-js": "^0.7.0",
|
|
93
93
|
"@promptctl/rich-js": "^0.6.0",
|
|
94
94
|
"json5": "^2.2.3",
|
|
95
95
|
"mobx": "^6.15.0"
|
|
96
96
|
},
|
|
97
97
|
"optionalDependencies": {
|
|
98
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
98
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.20.0",
|
|
99
|
+
"@promptctl/cc-candybar-darwin-x64": "1.20.0",
|
|
100
|
+
"@promptctl/cc-candybar-linux-x64": "1.20.0",
|
|
101
|
+
"@promptctl/cc-candybar-linux-arm64": "1.20.0"
|
|
102
102
|
},
|
|
103
103
|
"pnpm": {
|
|
104
104
|
"supportedArchitectures": {
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
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 { effectiveThemeName, effectiveStripStyle } from "./themes/policy.js";
|
|
34
|
+
import { resolverForThemeName } from "./themes/palette-resolvers.js";
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_CHARSET,
|
|
37
|
+
DEFAULT_COLOR_COMPATIBILITY,
|
|
38
|
+
DEFAULT_PADDING,
|
|
39
|
+
DEFAULT_WRAP,
|
|
40
|
+
} from "./render/strip.js";
|
|
41
|
+
|
|
42
|
+
// [LAW:no-ambient-temporal-coupling] A fixed width keeps the verdict a function
|
|
43
|
+
// of the config alone, not of whichever terminal invoked the check. Templates
|
|
44
|
+
// evaluate in full before any width-driven wrap/pagination, so width shapes
|
|
45
|
+
// layout, never diagnostics.
|
|
46
|
+
const CHECK_WIDTH = 200;
|
|
47
|
+
|
|
48
|
+
// One faked Claude Code hook event, shaped like the daemon's augmented payload
|
|
49
|
+
// (see src/daemon/render-payload.ts) — the `input` vars read out of it by their
|
|
50
|
+
// dotted `path`. [LAW:verifiable-goals] It is deliberately RICH (dirty git with
|
|
51
|
+
// every worktree count, an upstream, a stash, a recent commit; home set; live
|
|
52
|
+
// session/today/context/metrics/rate-limit data) so gated segments actually
|
|
53
|
+
// RENDER their content instead of gating off. A minimal payload would let a
|
|
54
|
+
// field-name typo in the git/directory/metrics/budget branches slip through —
|
|
55
|
+
// those branches only run when their data is present.
|
|
56
|
+
//
|
|
57
|
+
// `effectiveTheme` is threaded in exactly as the daemon threads it (server.ts
|
|
58
|
+
// resolves effectiveThemeName once and feeds BOTH the payload's
|
|
59
|
+
// `theme.effective` field and the basePalette below) [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(effectiveTheme: string): Record<string, unknown> {
|
|
65
|
+
const home = "/home/tester";
|
|
66
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
67
|
+
return {
|
|
68
|
+
hook_event_name: "Status",
|
|
69
|
+
session_id: "test0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b",
|
|
70
|
+
version: "1.15.0",
|
|
71
|
+
home,
|
|
72
|
+
cwd: `${home}/code/cc-candybar/src`,
|
|
73
|
+
transcript_path: `${home}/.claude/projects/x/test.jsonl`,
|
|
74
|
+
model: { id: "claude-opus-4-8", display_name: "Opus 4.8" },
|
|
75
|
+
workspace: {
|
|
76
|
+
current_dir: `${home}/code/cc-candybar/src`,
|
|
77
|
+
project_dir: `${home}/code/cc-candybar`,
|
|
78
|
+
},
|
|
79
|
+
git: {
|
|
80
|
+
repoName: "cc-candybar",
|
|
81
|
+
branch: "main",
|
|
82
|
+
sha: "abc1234",
|
|
83
|
+
ahead: 2,
|
|
84
|
+
behind: 1,
|
|
85
|
+
staged: 3,
|
|
86
|
+
unstaged: 2,
|
|
87
|
+
untracked: 1,
|
|
88
|
+
conflicts: 0,
|
|
89
|
+
upstream: "origin/main",
|
|
90
|
+
stash: 1,
|
|
91
|
+
status: "dirty",
|
|
92
|
+
operation: "rebase",
|
|
93
|
+
timeSinceCommit: 780,
|
|
94
|
+
},
|
|
95
|
+
session: { cost: 0.39, tokens: 241400 },
|
|
96
|
+
today: { cost: 12.5, tokens: 3_400_000 },
|
|
97
|
+
context: { totalTokens: 48487, contextLeft: 24 },
|
|
98
|
+
metrics: {
|
|
99
|
+
lastResponseTime: 8.2,
|
|
100
|
+
responseTime: 4.2,
|
|
101
|
+
sessionDuration: 930,
|
|
102
|
+
messageCount: 8,
|
|
103
|
+
linesAdded: 512,
|
|
104
|
+
linesRemoved: 88,
|
|
105
|
+
},
|
|
106
|
+
block: { nativeUtilization: 63, resetsAt: nowSec + 2 * 3600 },
|
|
107
|
+
weekly: { percentage: 21, resetsAt: nowSec + 5 * 86400 },
|
|
108
|
+
cache: { expiresAt: nowSec + 15 * 60 },
|
|
109
|
+
tmux: { session: "work" },
|
|
110
|
+
theme: { effective: effectiveTheme },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// [LAW:dataflow-not-control-flow] The check result is DATA — a pure function of
|
|
115
|
+
// the target file's contents — discriminated into the three outcomes the exit-
|
|
116
|
+
// code contract projects. `checkConfig` carries the decision; `runCheck` only
|
|
117
|
+
// maps it to (streams, exit), so the contract is testable without spawning a
|
|
118
|
+
// process or stubbing process.exit.
|
|
119
|
+
//
|
|
120
|
+
// `configPath` null means the bundled default was checked (no config file
|
|
121
|
+
// found — the daemon renders the same default in that state).
|
|
122
|
+
export type CheckOutcome =
|
|
123
|
+
| {
|
|
124
|
+
readonly kind: "clean";
|
|
125
|
+
readonly configPath: string | null;
|
|
126
|
+
readonly warnings: readonly string[];
|
|
127
|
+
readonly rendered: string;
|
|
128
|
+
}
|
|
129
|
+
| {
|
|
130
|
+
readonly kind: "fatal";
|
|
131
|
+
readonly configPath: string | null;
|
|
132
|
+
readonly message: string;
|
|
133
|
+
readonly warnings: readonly string[];
|
|
134
|
+
}
|
|
135
|
+
| {
|
|
136
|
+
readonly kind: "unreadable";
|
|
137
|
+
readonly path: string;
|
|
138
|
+
readonly message: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Run the daemon's load-and-render pipeline against one config target.
|
|
142
|
+
//
|
|
143
|
+
// With no target, the path resolves exactly as the daemon resolves it
|
|
144
|
+
// (resolveDslConfigPath: $CC_CANDYBAR_CONFIG → project/cwd → XDG), so the file
|
|
145
|
+
// this checks IS the file the daemon would load from this directory.
|
|
146
|
+
//
|
|
147
|
+
// [LAW:no-silent-failure] With an explicit target, the named file must exist
|
|
148
|
+
// and be readable — a missing file is `unreadable`, never a fall-through to the
|
|
149
|
+
// bundled default. (The daemon's --config-to-missing-file behavior — render the
|
|
150
|
+
// default, watch for the file to appear — is liveness for a long-running
|
|
151
|
+
// renderer; a verdict command must not report "clean" about a file it never
|
|
152
|
+
// read.)
|
|
153
|
+
export function checkConfig(
|
|
154
|
+
target: string | undefined,
|
|
155
|
+
cwd: string = process.cwd(),
|
|
156
|
+
): CheckOutcome {
|
|
157
|
+
// [LAW:one-source-of-truth] No pre-read: the ONE content read of the config
|
|
158
|
+
// file is the readFileSync inside loadConfig. Readability is established by
|
|
159
|
+
// the same read that parses (no double I/O); the catch below classifies its
|
|
160
|
+
// errno failure as `unreadable`. The explicit-target statSync is a metadata
|
|
161
|
+
// probe at the argv trust boundary, not a second read: a directory target
|
|
162
|
+
// (`check .`) fails read() with a path-less EISDIR the catch could not
|
|
163
|
+
// attribute, so the not-a-file usage error is decided here.
|
|
164
|
+
const configPath =
|
|
165
|
+
target !== undefined
|
|
166
|
+
? path.resolve(expandHome(target))
|
|
167
|
+
: resolveDslConfigPath(cwd, cwd);
|
|
168
|
+
if (target !== undefined && configPath !== null) {
|
|
169
|
+
// throwIfNoEntry suppresses only ENOENT (left for the content read to
|
|
170
|
+
// classify); EACCES/EPERM on the probe itself is equally "could not read
|
|
171
|
+
// the named file" — same outcome, not an uncaught stack.
|
|
172
|
+
let st: fs.Stats | undefined;
|
|
173
|
+
try {
|
|
174
|
+
st = fs.statSync(configPath, { throwIfNoEntry: false });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return {
|
|
177
|
+
kind: "unreadable",
|
|
178
|
+
path: configPath,
|
|
179
|
+
message: e instanceof Error ? e.message : String(e),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (st !== undefined && !st.isFile()) {
|
|
183
|
+
return {
|
|
184
|
+
kind: "unreadable",
|
|
185
|
+
path: configPath,
|
|
186
|
+
message: "not a file",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// [LAW:dataflow-not-control-flow] Collision detection runs independent of
|
|
192
|
+
// load success — mirror of RenderCache.reloadInto: even if the .json5 fails
|
|
193
|
+
// to parse, the author still wants to know a shadowed .json sibling exists.
|
|
194
|
+
const warnings: string[] = [];
|
|
195
|
+
const collision = detectConfigCollisions(cwd, cwd);
|
|
196
|
+
if (collision !== null) warnings.push(collision);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const rendered = loadRegisterRender(configPath, cwd, warnings);
|
|
200
|
+
return { kind: "clean", configPath, warnings, rendered };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// A filesystem error on the config file itself (ENOENT/EACCES from
|
|
203
|
+
// loadConfig's read — errno errors carry the failing `.path`, which is the
|
|
204
|
+
// discriminator against deeper fs failures) is the `unreadable` outcome:
|
|
205
|
+
// the named file could not be read at all, distinct from a file that read
|
|
206
|
+
// but is invalid. [LAW:no-silent-failure] — never a fall-through to the
|
|
207
|
+
// bundled default. Duck-typed, not `instanceof Error`: fs errors can cross
|
|
208
|
+
// a realm boundary (jest/graceful-fs), where instanceof lies.
|
|
209
|
+
const errno = e as Partial<NodeJS.ErrnoException> | null;
|
|
210
|
+
if (
|
|
211
|
+
configPath !== null &&
|
|
212
|
+
typeof errno === "object" &&
|
|
213
|
+
errno !== null &&
|
|
214
|
+
typeof errno.code === "string" &&
|
|
215
|
+
errno.path === configPath &&
|
|
216
|
+
typeof errno.message === "string"
|
|
217
|
+
) {
|
|
218
|
+
return { kind: "unreadable", path: configPath, message: errno.message };
|
|
219
|
+
}
|
|
220
|
+
// Same classification RenderCache.reloadInto applies: ConfigError and
|
|
221
|
+
// register/render throws (template parse, MissingFieldError, action arity)
|
|
222
|
+
// are all author-facing diagnostics — the daemon would surface each via
|
|
223
|
+
// composeWithDiagnostics, so check surfaces each as fatal text.
|
|
224
|
+
const message =
|
|
225
|
+
e instanceof ConfigError
|
|
226
|
+
? e.message
|
|
227
|
+
: e instanceof Error
|
|
228
|
+
? e.message
|
|
229
|
+
: String(e);
|
|
230
|
+
return { kind: "fatal", configPath, message, warnings };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The buildState + per-request-render mirror: every call below is the function
|
|
235
|
+
// the daemon calls, in the daemon's order [LAW:single-enforcer]. Returns the
|
|
236
|
+
// rendered line; appends the register pass's advisory `loadWarnings` (partial
|
|
237
|
+
// declaration failures) to `warnings` — the same channel RenderCache merges
|
|
238
|
+
// them into.
|
|
239
|
+
function loadRegisterRender(
|
|
240
|
+
configPath: string | null,
|
|
241
|
+
cwd: string,
|
|
242
|
+
warnings: string[],
|
|
243
|
+
): string {
|
|
244
|
+
const { config: merged, source } = loadConfig(configPath);
|
|
245
|
+
const config = validateConfig(merged, configPath ?? "<default>", source);
|
|
246
|
+
|
|
247
|
+
const store = new VariableStore();
|
|
248
|
+
const registry = new SourceRegistry(
|
|
249
|
+
store,
|
|
250
|
+
config.globals.default_empty_value ?? "",
|
|
251
|
+
undefined,
|
|
252
|
+
new SessionState(),
|
|
253
|
+
);
|
|
254
|
+
try {
|
|
255
|
+
const compiled = registerDslConfig(config, registry, { cwd });
|
|
256
|
+
// Registered before the validator pass so a derive throw (a key-kind
|
|
257
|
+
// clash) still carries the partial-load warnings into the fatal outcome.
|
|
258
|
+
warnings.push(...compiled.loadWarnings);
|
|
259
|
+
// Derivation only (the throw-on-clash coherence pass over the action
|
|
260
|
+
// table); the daemon additionally registers the results in its global
|
|
261
|
+
// validator registry, which a one-shot check has no wire to serve.
|
|
262
|
+
deriveActionValidators(config);
|
|
263
|
+
|
|
264
|
+
// Fresh session (no clicked theme/style), so the session half of each
|
|
265
|
+
// resolution is null — the config default over the floor, exactly what the
|
|
266
|
+
// daemon renders for a session that has never clicked.
|
|
267
|
+
const effectiveTheme = effectiveThemeName(null, config.globals.palette);
|
|
268
|
+
return renderDsl(
|
|
269
|
+
config,
|
|
270
|
+
compiled,
|
|
271
|
+
store,
|
|
272
|
+
registry,
|
|
273
|
+
checkPayload(effectiveTheme),
|
|
274
|
+
resolverForThemeName(effectiveTheme),
|
|
275
|
+
{
|
|
276
|
+
style: effectiveStripStyle(null, config.globals.style),
|
|
277
|
+
width: CHECK_WIDTH,
|
|
278
|
+
colorCompatibility:
|
|
279
|
+
config.globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
|
|
280
|
+
wrap: config.globals.autoWrap ?? DEFAULT_WRAP,
|
|
281
|
+
padding: config.globals.padding ?? DEFAULT_PADDING,
|
|
282
|
+
charset: config.globals.charset ?? DEFAULT_CHARSET,
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
} finally {
|
|
286
|
+
// [LAW:single-enforcer] The registry owns every async handle the config
|
|
287
|
+
// declared (timers, fs watchers, git subscriptions); a one-shot check must
|
|
288
|
+
// not leak them past the verdict.
|
|
289
|
+
registry.dispose();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const EXIT_CLEAN = 0;
|
|
294
|
+
const EXIT_FATAL = 1;
|
|
295
|
+
const EXIT_USAGE = 2;
|
|
296
|
+
|
|
297
|
+
// [LAW:dataflow-not-control-flow] The outcome → (streams, exit-code) mapping is
|
|
298
|
+
// DATA: a total fold over CheckOutcome returning one descriptor; runCheck runs
|
|
299
|
+
// the two unconditional writes + exit against it. Verdict on stdout, every
|
|
300
|
+
// diagnostic (warnings included) on stderr — so `check` in a pipeline yields a
|
|
301
|
+
// parseable verdict while a human still sees the advisories.
|
|
302
|
+
export interface CheckPlan {
|
|
303
|
+
readonly stdout: string;
|
|
304
|
+
readonly stderr: string;
|
|
305
|
+
readonly code: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function warningLines(warnings: readonly string[]): string {
|
|
309
|
+
return warnings.map((w) => `warning: ${w}\n`).join("");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function checkPlan(o: CheckOutcome): CheckPlan {
|
|
313
|
+
switch (o.kind) {
|
|
314
|
+
case "clean": {
|
|
315
|
+
const where = o.configPath ?? "bundled default (no config file found)";
|
|
316
|
+
const count =
|
|
317
|
+
o.warnings.length > 0
|
|
318
|
+
? ` (${o.warnings.length} warning${o.warnings.length === 1 ? "" : "s"})`
|
|
319
|
+
: "";
|
|
320
|
+
return {
|
|
321
|
+
stdout: `✓ ${where}: config OK${count}\n`,
|
|
322
|
+
stderr: warningLines(o.warnings),
|
|
323
|
+
code: EXIT_CLEAN,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
case "fatal":
|
|
327
|
+
return {
|
|
328
|
+
stdout: "",
|
|
329
|
+
stderr:
|
|
330
|
+
warningLines(o.warnings) +
|
|
331
|
+
`✗ ${o.configPath ?? "<default>"}\n${o.message}\n`,
|
|
332
|
+
code: EXIT_FATAL,
|
|
333
|
+
};
|
|
334
|
+
case "unreadable":
|
|
335
|
+
return {
|
|
336
|
+
stdout: "",
|
|
337
|
+
stderr: `check: cannot read ${o.path}: ${o.message}\n`,
|
|
338
|
+
code: EXIT_USAGE,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// `cc-candybar check [path]` — the argv binding. Extra arguments and an empty
|
|
344
|
+
// path argument are usage errors (loud, not silently ignored — the likeliest
|
|
345
|
+
// cause is an unquoted or mis-expanded shell variable). An empty string is not
|
|
346
|
+
// "no argument": `checkConfig(undefined)` means "resolve like the daemon",
|
|
347
|
+
// while `""` is a malformed target that would otherwise EISDIR on the cwd.
|
|
348
|
+
export function runCheck(args: readonly string[]): never {
|
|
349
|
+
if (args.length > 1 || args[0] === "") {
|
|
350
|
+
process.stderr.write(
|
|
351
|
+
"check: expected at most one non-empty path\nUsage: cc-candybar check [config-file]\n",
|
|
352
|
+
);
|
|
353
|
+
process.exit(EXIT_USAGE);
|
|
354
|
+
}
|
|
355
|
+
const plan = checkPlan(checkConfig(args[0]));
|
|
356
|
+
process.stdout.write(plan.stdout);
|
|
357
|
+
process.stderr.write(plan.stderr);
|
|
358
|
+
process.exit(plan.code);
|
|
359
|
+
}
|
package/src/config/cli.ts
CHANGED
|
@@ -1,128 +1,17 @@
|
|
|
1
|
-
// [LAW:single-enforcer] Config-tooling CLI entry
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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). " +
|
|
@@ -528,12 +528,9 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
528
528
|
// and the render's read are one value. Empty default ⇒ the daemon's
|
|
529
529
|
// "powerline" floor is in effect and styleControl shows "(default)".
|
|
530
530
|
activeStyle: { kind: "state", key: "style", default: "" },
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
// every toggle, so a reopened menu never lands on a stale page). The stylePage
|
|
535
|
-
// action below declares the int gate this var reads back.
|
|
536
|
-
stylePage: { kind: "state", key: "style-page", default: "0" },
|
|
531
|
+
// No page-cursor var: the styleControl {{ menu }} synthesizes its own page
|
|
532
|
+
// cursor (state var + int action, named by menuPageKey) under the reserved
|
|
533
|
+
// menus.* namespace, alongside its open-state.
|
|
537
534
|
},
|
|
538
535
|
|
|
539
536
|
// ─── Segments ──────────────────────────────────────────────────────────────
|
|
@@ -794,15 +791,16 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
794
791
|
// onto the line below this row when open. [LAW:one-type-per-behavior] "a menu
|
|
795
792
|
// that opens and closes" is one behavior the substrate already expresses — no
|
|
796
793
|
// bespoke open-action + page-cursor-as-open-state + when-gated reveal row.
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
794
|
+
// The apply name is the whole declaration: the page cursor (state var + int
|
|
795
|
+
// gate) is synthesized from the menu's identity, and the defaults are the
|
|
796
|
+
// canonical path — paged (the 3 powerline shapes fit one page, so no arrows
|
|
797
|
+
// render) and stay-open, so shapes can be tried in a row; ▾/✕ collapse.
|
|
800
798
|
// [LAW:dataflow-not-control-flow] No display state from the provider — the
|
|
801
799
|
// label is the one "style" value the click writes and the render reads.
|
|
802
800
|
styleControl: {
|
|
803
801
|
template:
|
|
804
802
|
"✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
|
|
805
|
-
'{{ menu "applyStyle"
|
|
803
|
+
'{{ menu "applyStyle" }}',
|
|
806
804
|
bg: "surface",
|
|
807
805
|
fg: "foreground",
|
|
808
806
|
},
|
|
@@ -876,18 +874,17 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
876
874
|
openProject: { open: "{{ .project_dir }}" },
|
|
877
875
|
openTranscript: { open: "{{ .transcript_path }}" },
|
|
878
876
|
|
|
879
|
-
// [LAW:locality-or-seam] The style menu's
|
|
880
|
-
// styleControl {{ menu }} above. The disclosure's open-state toggle
|
|
881
|
-
// backing state var
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
877
|
+
// [LAW:locality-or-seam] The style menu's behavior, decoupled by NAME from the
|
|
878
|
+
// styleControl {{ menu }} above. The disclosure's open-state toggle, its
|
|
879
|
+
// backing state var, AND the picker body's page cursor (state var + int
|
|
880
|
+
// action) are all SYNTHESIZED by the menu pass (under the reserved menus.*
|
|
881
|
+
// namespace) — no hand-authored open/close or page plumbing. This one action
|
|
882
|
+
// is the picker body's apply effect, gated by derivation
|
|
883
|
+
// (deriveActionValidators): it writes the chosen shape, gated to the
|
|
884
|
+
// STRIP_STYLES allow-list because its value source is `from: "styles"`. The
|
|
887
885
|
// rendered click and the wire gate share that one source — a template cannot
|
|
888
886
|
// smuggle an un-gated style write.
|
|
889
887
|
applyStyle: { set: "style", from: "styles" },
|
|
890
|
-
stylePage: { set: "style-page", int: true },
|
|
891
888
|
},
|
|
892
889
|
|
|
893
890
|
// [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] THE disclosure primitive: the one toggle machinery
|
|
2
|
+
// that both group sugar (`kind: "group"`, src/config/loader/layout.ts) and the
|
|
3
|
+
// `{{ menu }}` helper (src/config/loader/menu-synth.ts + src/render/menu.ts) are
|
|
4
|
+
// built on. A disclosure is a binary toggle over a SessionState key: the key
|
|
5
|
+
// holds either the CLOSED sentinel or a single MEMBER name; a `cycle` action
|
|
6
|
+
// flips between the two; a ▸/▾ glyph shows which state it is in; sibling
|
|
7
|
+
// disclosures sharing one key become mutually exclusive (an accordion) because
|
|
8
|
+
// one key holds one open member. Group and menu differ ONLY in their BODY (group
|
|
9
|
+
// reveals an arbitrary layout container gated by a `when`; menu drops a picker
|
|
10
|
+
// grid below the row) and in where the trigger lives (group synthesizes a toggle
|
|
11
|
+
// segment; the menu helper IS the trigger). The toggle itself — sentinel, glyphs,
|
|
12
|
+
// the `state` var, the `cycle` action — is single-sourced HERE so the two
|
|
13
|
+
// body-kinds cannot drift [LAW:one-type-per-behavior][LAW:decomposition].
|
|
14
|
+
//
|
|
15
|
+
// [LAW:one-way-deps] This module is intentionally PURE — it imports only decl
|
|
16
|
+
// TYPES (erased at build) and holds no loader (`ValidateCtx`, diagnostics) nor
|
|
17
|
+
// render (rich-js) dependency, so both the loader synthesis passes and the render
|
|
18
|
+
// helper can share it without dragging one layer into the other. The loader-side
|
|
19
|
+
// reserved-namespace collision check — the other half of the shared machinery,
|
|
20
|
+
// which needs the validation context — lives in
|
|
21
|
+
// `src/config/loader/reserved-namespace.ts`.
|
|
22
|
+
|
|
23
|
+
import type { ActionDecl } from "./action.js";
|
|
24
|
+
import type { VariableDecl } from "./dsl-types.js";
|
|
25
|
+
|
|
26
|
+
// The "nothing open" sentinel a disclosure's key starts from and returns to on
|
|
27
|
+
// close. A disclosure's MEMBER (a group name / a menu apply-action name) may
|
|
28
|
+
// never equal this — an equal member would make the cycle `[closed, "closed"]`
|
|
29
|
+
// (two identical members, never openable), which both synthesis passes reject.
|
|
30
|
+
export const DISCLOSURE_CLOSED = "closed";
|
|
31
|
+
|
|
32
|
+
// [LAW:representation] The disclosure glyph vocabulary — one pair for the whole
|
|
33
|
+
// bar so every disclosure reads the same (trailing the label/content it gates,
|
|
34
|
+
// per pdu.8): collapsed ▸, expanded ▾.
|
|
35
|
+
export const DISCLOSURE_GLYPH_CLOSED = "▸";
|
|
36
|
+
export const DISCLOSURE_GLYPH_OPEN = "▾";
|
|
37
|
+
|
|
38
|
+
// [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
|
|
39
|
+
// it holds the open member's name and defaults to `def` (the CLOSED sentinel for
|
|
40
|
+
// an independent disclosure, or an initially-open member for a group's
|
|
41
|
+
// `open: true`). One shape, so a group var and a menu var declared on one key
|
|
42
|
+
// cannot disagree on kind or key.
|
|
43
|
+
export function disclosureStateVar(key: string, def: string): VariableDecl {
|
|
44
|
+
return { kind: "state", key, default: def };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// [LAW:single-enforcer] THE toggle action a disclosure realizes: a binary `cycle`
|
|
48
|
+
// between the CLOSED sentinel and the member (ordered closed-first, so an unset or
|
|
49
|
+
// sibling-held key counts as the first member — the toggle renders ▸ and a click
|
|
50
|
+
// writes the member, opening it and auto-closing any accordion sibling). The
|
|
51
|
+
// derived click gate (`deriveActionValidators`) reads this like every other
|
|
52
|
+
// `set`, so a disclosure toggle needs no parallel verb [LAW:single-enforcer].
|
|
53
|
+
export function disclosureCycleAction(key: string, member: string): ActionDecl {
|
|
54
|
+
return { set: key, cycle: [DISCLOSURE_CLOSED, member] };
|
|
55
|
+
}
|