@promptctl/cc-candybar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,113 @@
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
+
54
+ // [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
55
+ // positional args. The wire owns all encoding — callers never percent-encode.
56
+ // set-state's args are `[sessionId, key, value, …]`; copy/open carry one arg.
57
+ export interface Effect {
58
+ readonly verb: string;
59
+ readonly args: readonly string[];
60
+ }
61
+
62
+ // [LAW:types-are-the-program] A parsed effect as the dispatcher sees it: the verb
63
+ // and the still-encoded segment tail. The tail stays encoded because the target
64
+ // verb's handler decodes its own segments at its boundary (single-enforcer per
65
+ // verb) — the same contract a direct (non-dispatch) click URL hands a handler.
66
+ export interface ParsedEffect {
67
+ readonly verb: string;
68
+ readonly value: string;
69
+ }
70
+
71
+ // [LAW:single-enforcer] The segment codec. A verb's args serialize to a
72
+ // slash-joined run of percent-encoded segments; the handler decodes the inverse.
73
+ // Encoding each segment means a segment's own `/` becomes `%2F` and never reads
74
+ // as a separator — the slash-safety the old whole-value decode could not give.
75
+ export function encodeSegments(parts: readonly string[]): string {
76
+ return parts.map(encodeURIComponent).join("/");
77
+ }
78
+
79
+ export function decodeSegments(value: string): string[] {
80
+ return value.length === 0 ? [] : value.split("/").map(decodeURIComponent);
81
+ }
82
+
83
+ // Serialize an effect list to its dispatch URL. Each effect becomes one ordered
84
+ // `e` query param carrying `verb/<encoded-args>`, percent-encoded whole so its
85
+ // internal `/`, `&`, `=` survive as data. The payload follows `dispatch/` (not
86
+ // `dispatch?`) so `/` is the only verb delimiter parseHandlerUrl needs.
87
+ export function effectsUrl(effects: readonly Effect[]): string {
88
+ const qs = effects
89
+ .map(
90
+ (e) => `e=${encodeURIComponent(`${e.verb}/${encodeSegments(e.args)}`)}`,
91
+ )
92
+ .join("&");
93
+ return `${URL_SCHEME}://${VERB_DISPATCH}/${qs}`;
94
+ }
95
+
96
+ // [LAW:dataflow-not-control-flow] Parse the dispatch verb's raw value (an
97
+ // `e=…&e=…` query string) into the ordered effect list. URLSearchParams decodes
98
+ // each param exactly once and preserves insertion order; splitting each on the
99
+ // FIRST `/` recovers (verb, still-encoded tail) — the same split parseHandlerUrl
100
+ // applies at the top level, one level down.
101
+ export function parseEffects(rawValue: string): ParsedEffect[] {
102
+ return new URLSearchParams(rawValue).getAll("e").map(splitVerb);
103
+ }
104
+
105
+ // [LAW:types-are-the-program] Split a `verb/tail` string at the first `/`. A
106
+ // verb with no args (no slash) yields an empty tail — the degenerate case, not a
107
+ // guard. The tail keeps its slashes (further segments) for the handler to decode.
108
+ export function splitVerb(s: string): ParsedEffect {
109
+ const i = s.indexOf("/");
110
+ return i === -1
111
+ ? { verb: s, value: "" }
112
+ : { verb: s.slice(0, i), value: s.slice(i + 1) };
113
+ }
@@ -0,0 +1,91 @@
1
+ // [LAW:types-are-the-program] The author-facing SHAPE of a decoupled, named
2
+ // ACTION — the config-file schema for `DslConfig.actions`. Pure data (no engine,
3
+ // no rich-js): the strongest theorem about what a user can declare. The loader
4
+ // narrows `unknown` to these; the runtime (render/action.ts) and the validator
5
+ // derivation (daemon/verbs/state-validators.ts) consume them.
6
+ //
7
+ // [LAW:locality-or-seam] An action is the SEAM between the clickable
8
+ // REPRESENTATION (a template region, `{{ action "name" … }}`) and the BEHAVIOR
9
+ // (what the click does). They are joined by NAME: re-glyph a button without
10
+ // touching behavior; re-target an action without touching the template. This is
11
+ // the successor to the widget surface — a widget couples representation and
12
+ // behavior in one declaration; an action splits them so one template expresses
13
+ // anything (text, state-driven display, clickable regions) and the action table
14
+ // is the single, statically-enumerable set of effects those regions can fire.
15
+ //
16
+ // [LAW:one-source-of-truth] Because a `set` action carries its key and value
17
+ // SOURCE as literal data (a literal `to`, an option domain, or numeric bounds),
18
+ // the writable-key gate DERIVES from the action table (deriveActionValidators).
19
+ // A template references a NAME; it cannot smuggle an un-gated write. The rendered
20
+ // click and the gate share ONE source — the action declaration.
21
+ //
22
+ // [LAW:one-source-of-truth] The option-domain + effect-verb vocabulary lives
23
+ // HERE — action.ts is the surviving home now that the widget surface is gone.
24
+ // These are the shapes a picker draws options from and the set/copy/open/int
25
+ // discriminator the loader and the validator-derivation match on.
26
+
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"];
33
+
34
+ // [LAW:types-are-the-program] The top-level discriminator of an ActionDecl — the
35
+ // click effect is keyed by which of these is present. The loader proves
36
+ // exactly-one-of; the renderer and validator-derivation match with no fallthrough.
37
+ export const ACTION_KEYS = ["set", "copy", "open"] as const;
38
+ export type ActionKey = (typeof ACTION_KEYS)[number];
39
+
40
+ // [LAW:types-are-the-program] An ActionDecl is the click effect a named action
41
+ // binds to. The top-level discriminator is which of `set`/`copy`/`open` is
42
+ // present (the CacheDecl/widget-Action pattern); a `set` is sub-discriminated by
43
+ // its value SOURCE — `to` (literal), `from` (option-bound), or `min/max/by`
44
+ // (bounded step). The loader proves exactly-one-of at each level; the renderer
45
+ // and the validator-derivation match on the present key with no fallthrough.
46
+ //
47
+ // set + to — write a literal value -> allow-list {to}
48
+ // set + from — write the option the template binds at render
49
+ // (a picker ranges the domain) -> allow-list {options}
50
+ // set + min/max/by — write wrap(current ± by) clamped to [min,max]
51
+ // (a stepper affordance) -> range [min,max]
52
+ // set + int — write any integer the render binds (a paged cursor:
53
+ // -1 closed / 0..N pages, clamp owned by the renderer)
54
+ // -> int gate (unbounded). The missing primitive a
55
+ // width-paginated picker needs — its page key accepts any
56
+ // integer, which no bounded/literal arm can express.
57
+ // set + cycle — write the SUCCESSOR of the current value in the
58
+ // member list, wrapping; a current value outside the
59
+ // domain counts as the first member (so the second
60
+ // member is the "first click" target — order members
61
+ // default-state-first). The bounded stepper's sibling:
62
+ // a stepper steps a range, a cycle steps an enumerated
63
+ // domain (toggles, N-state cyclers, accordion paths)
64
+ // -> allow-list {members}
65
+ // copy — copy templated text to the clipboard -> no gate
66
+ // open — open a templated target in the editor -> no gate
67
+ //
68
+ // [LAW:one-source-of-truth] Only `set` writes SessionState, so only `set`
69
+ // derives a validator. copy/open write nothing — they derive nothing. The
70
+ // vocabulary grows by arms (a future `run`/`open-url`), not by validator plumbing.
71
+ export type ActionDecl =
72
+ | { readonly set: string; readonly to: string }
73
+ | { readonly set: string; readonly from: OptionSource }
74
+ | {
75
+ readonly set: string;
76
+ readonly min: number;
77
+ readonly max: number;
78
+ readonly by: number;
79
+ }
80
+ | { readonly set: string; readonly int: true }
81
+ | { readonly set: string; readonly cycle: readonly string[] }
82
+ | { readonly copy: string }
83
+ | { readonly open: string };
84
+
85
+ // [LAW:dataflow-not-control-flow] Does this action write a SessionState key? A
86
+ // `set` action composes a set-state click URL whose first segment is session.id;
87
+ // copy/open embed none. One predicate the loader's session.id requirement folds
88
+ // over — no per-arm branching at the callsite.
89
+ export function actionBindsSet(a: ActionDecl): boolean {
90
+ return "set" in a;
91
+ }
@@ -0,0 +1,170 @@
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].
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import process from "node:process";
11
+ import { loadConfig, validateConfig } from "./dsl-loader.js";
12
+ import { ConfigError } from "./loader/diagnostics.js";
13
+
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
+ const EXIT_VALID = 0;
20
+ const EXIT_INVALID = 1;
21
+ const EXIT_USAGE = 2;
22
+
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
+ // Read the build-generated JSON Schema for the config file shape (RawDslConfig),
127
+ // or null when the artifact is absent. Pure read — `runSchema` owns the side
128
+ // effects, so the locate-and-read path is testable.
129
+ export function loadSchemaText(): string | null {
130
+ const schemaPath = locateSchema();
131
+ return schemaPath === null ? null : fs.readFileSync(schemaPath, "utf-8");
132
+ }
133
+
134
+ // `cc-candybar schema` — print the schema so an editor can annotate a config with
135
+ // `"$schema": "<this output, saved somewhere>"` (or the stable published URL) for
136
+ // autocomplete + structural validation. Emitted from the loader schemas at build
137
+ // time (scripts/gen-schema.ts → emitConfigSchema); served verbatim here (the
138
+ // emitter runs at build, not ship time).
139
+ export function runSchema(): void {
140
+ const text = loadSchemaText();
141
+ if (text === null) {
142
+ process.stderr.write(
143
+ "schema: bundled schema not found (expected schema/cc-candybar.schema.json). " +
144
+ "Run `pnpm gen:schema` from a source checkout.\n",
145
+ );
146
+ process.exit(EXIT_USAGE);
147
+ }
148
+ process.stdout.write(text);
149
+ process.exit(EXIT_VALID);
150
+ }
151
+
152
+ // [LAW:locality-or-seam] The schema sits at `<package-root>/schema/...`. Anchor
153
+ // on the running CLI entry (argv[1] — `dist/index.mjs` in prod, since every
154
+ // subcommand execs the Node fallback) and walk up to the nearest ancestor that
155
+ // holds the artifact, so the prod and dev layouts aren't special-cased. argv[1]
156
+ // (not import.meta) keeps this resolvable identically under the ESM bundle and
157
+ // any module setting.
158
+ function locateSchema(): string | null {
159
+ const rel = path.join("schema", "cc-candybar.schema.json");
160
+ const anchor = process.argv[1];
161
+ if (anchor === undefined) return null;
162
+ let dir = path.dirname(path.resolve(anchor));
163
+ for (;;) {
164
+ const candidate = path.join(dir, rel);
165
+ if (fs.existsSync(candidate)) return candidate;
166
+ const parent = path.dirname(dir);
167
+ if (parent === dir) return null;
168
+ dir = parent;
169
+ }
170
+ }