@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// [LAW:single-enforcer] One home for config-error reporting: the public issue/
|
|
2
|
+
// error types, the best-effort source-line lookup every validator calls, and the
|
|
3
|
+
// human-readable formatter ConfigError renders. Changes here are display/source-
|
|
4
|
+
// mapping changes; the schema validators never touch this file.
|
|
5
|
+
|
|
6
|
+
// ─── Public types ────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface ConfigIssue {
|
|
9
|
+
/** Dotted logical path inside the config (e.g., "variables.foo.cache"). */
|
|
10
|
+
readonly path: string;
|
|
11
|
+
/** Short, actionable description of the problem. */
|
|
12
|
+
readonly message: string;
|
|
13
|
+
/** Source line (1-based). For semantic errors, best-effort from the path. */
|
|
14
|
+
readonly line?: number;
|
|
15
|
+
/** Source column (1-based). Present only for parse errors. */
|
|
16
|
+
readonly col?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ConfigError extends Error {
|
|
20
|
+
readonly file: string;
|
|
21
|
+
readonly issues: readonly ConfigIssue[];
|
|
22
|
+
|
|
23
|
+
constructor(file: string, issues: readonly ConfigIssue[]) {
|
|
24
|
+
super(formatIssues(file, issues));
|
|
25
|
+
this.name = "ConfigError";
|
|
26
|
+
this.file = file;
|
|
27
|
+
this.issues = issues;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Best-effort source-line lookup ──────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
// Walk source forward, finding each path component as a JSON5 key in turn.
|
|
34
|
+
// JSON5 keys are unquoted identifiers (`foo:`), double-quoted strings, or
|
|
35
|
+
// single-quoted strings. Numeric path parts (e.g., layout indices) are
|
|
36
|
+
// skipped — they point inside arrays where line lookup is less useful.
|
|
37
|
+
//
|
|
38
|
+
// This is "good enough" navigation, not a guarantee. Returns undefined if a
|
|
39
|
+
// path part can't be located — the caller falls back to the logical path.
|
|
40
|
+
export function findKeyLine(
|
|
41
|
+
source: string,
|
|
42
|
+
pathParts: readonly string[],
|
|
43
|
+
): number | undefined {
|
|
44
|
+
let cursor = 0;
|
|
45
|
+
let foundCursor: number | undefined;
|
|
46
|
+
for (const part of pathParts) {
|
|
47
|
+
if (part === "" || /^\d+$/.test(part)) continue;
|
|
48
|
+
const found = findKeyOccurrence(source, cursor, part);
|
|
49
|
+
if (found === -1) {
|
|
50
|
+
return foundCursor !== undefined
|
|
51
|
+
? lineFromOffset(source, foundCursor)
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
cursor = found;
|
|
55
|
+
foundCursor = found;
|
|
56
|
+
}
|
|
57
|
+
return foundCursor !== undefined
|
|
58
|
+
? lineFromOffset(source, foundCursor)
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findKeyOccurrence(source: string, from: number, key: string): number {
|
|
63
|
+
// Match `<key>:` or `"<key>":` or `'<key>':` — any whitespace before the colon
|
|
64
|
+
// is allowed by JSON5. Escape regex specials in key.
|
|
65
|
+
const escaped = key.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
66
|
+
const re = new RegExp(`(?:["']${escaped}["']|\\b${escaped}\\b)\\s*:`, "g");
|
|
67
|
+
re.lastIndex = from;
|
|
68
|
+
const m = re.exec(source);
|
|
69
|
+
return m ? m.index : -1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lineFromOffset(source: string, offset: number): number {
|
|
73
|
+
let line = 1;
|
|
74
|
+
for (let i = 0; i < offset && i < source.length; i++) {
|
|
75
|
+
if (source.charCodeAt(i) === 0x0a) line++;
|
|
76
|
+
}
|
|
77
|
+
return line;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Error formatting ────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function formatIssues(file: string, issues: readonly ConfigIssue[]): string {
|
|
83
|
+
if (issues.length === 0) return `${file}: invalid config (no details)`;
|
|
84
|
+
const lines: string[] = [
|
|
85
|
+
`Invalid config in ${file} (${issues.length} issue${issues.length === 1 ? "" : "s"}):`,
|
|
86
|
+
];
|
|
87
|
+
for (const issue of issues) {
|
|
88
|
+
const locParts: string[] = [];
|
|
89
|
+
if (issue.line !== undefined) {
|
|
90
|
+
locParts.push(
|
|
91
|
+
`line ${issue.line}${issue.col !== undefined ? `:${issue.col}` : ""}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (issue.path) locParts.push(issue.path);
|
|
95
|
+
const loc = locParts.length > 0 ? `[${locParts.join(" • ")}] ` : "";
|
|
96
|
+
lines.push(` ${loc}${issue.message}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// [LAW:single-enforcer] Config-file discovery: where the DSL config can live, in
|
|
2
|
+
// what precedence, and how `~` expands. One candidate-path enumerator feeds the
|
|
3
|
+
// resolver, the watchers, and the collision detector so none can disagree about
|
|
4
|
+
// which files are candidates. This file changes when the resolution rules change.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
// [LAW:one-source-of-truth] The set of accepted extensions lives here once.
|
|
11
|
+
// Both .json5 and .json are accepted: JSON ⊂ JSON5, so the same parser
|
|
12
|
+
// (JSON5.parse) handles both — only the filename lookup varies. Ordering is
|
|
13
|
+
// load-bearing: .json5 wins over .json at the same location (documented
|
|
14
|
+
// format > compatibility tail).
|
|
15
|
+
const CONFIG_EXTENSIONS = ["json5", "json"] as const;
|
|
16
|
+
|
|
17
|
+
// [LAW:single-enforcer] One implementation of `~`-prefix expansion, called at
|
|
18
|
+
// each trust boundary that takes a user-supplied path. The CLI `--config`
|
|
19
|
+
// value is expanded in `parseRenderArgs` (server.ts) before it ever reaches
|
|
20
|
+
// here; `CC_CANDYBAR_CONFIG` is expanded below where the env var is read.
|
|
21
|
+
// One function, one rule, two callers.
|
|
22
|
+
//
|
|
23
|
+
// [LAW:enumeration-gap] Only the shell-standard home-expansion forms trigger
|
|
24
|
+
// replacement: bare `~`, `~/...`, or `~\...` on Windows. A string like
|
|
25
|
+
// `~alice/cfg` (POSIX named-home lookup) is NOT expanded — we have no way
|
|
26
|
+
// to resolve another user's home and a literal substitution would corrupt
|
|
27
|
+
// the path (`<homedir>alice/cfg`).
|
|
28
|
+
export function expandHome(p: string): string {
|
|
29
|
+
return p === "~" || p.startsWith("~/") || p.startsWith("~\\")
|
|
30
|
+
? os.homedir() + p.slice(1)
|
|
31
|
+
: p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The full ordered list of candidate paths the DSL config could live at,
|
|
36
|
+
* for a given (projectDir, cwd). Returned regardless of which exist — the
|
|
37
|
+
* cache uses this to watch every candidate location so the creation of any
|
|
38
|
+
* file in the resolution chain triggers hot-reload.
|
|
39
|
+
*
|
|
40
|
+
* `configFile` is the highest-precedence entry — the path resolved from the
|
|
41
|
+
* client's `--config` flag (already `~`-expanded at the trust boundary in
|
|
42
|
+
* server.ts). When present, it is the sole candidate and the rest of the
|
|
43
|
+
* precedence chain is bypassed.
|
|
44
|
+
*
|
|
45
|
+
* [LAW:single-enforcer] One enumerator; `resolveDslConfigPath` finds the
|
|
46
|
+
* first that exists, watchers listen on all of them, no second list.
|
|
47
|
+
*
|
|
48
|
+
* [LAW:dataflow-not-control-flow] Location is the dominant precedence axis;
|
|
49
|
+
* extension breaks ties within a location. Encoded as a nested flat-map: each
|
|
50
|
+
* location yields one path per extension in order. No branches on extension.
|
|
51
|
+
*/
|
|
52
|
+
export function dslConfigCandidatePaths(
|
|
53
|
+
projectDir?: string,
|
|
54
|
+
cwd?: string,
|
|
55
|
+
configFile?: string,
|
|
56
|
+
): readonly string[] {
|
|
57
|
+
// CLI --config wins over everything — highest precedence. Pre-expanded at
|
|
58
|
+
// the trust boundary; trust the type here.
|
|
59
|
+
if (configFile) {
|
|
60
|
+
return [configFile];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const envPath = process.env.CC_CANDYBAR_CONFIG;
|
|
64
|
+
if (envPath) {
|
|
65
|
+
// [LAW:single-enforcer] env-var is a separate trust boundary; expand here
|
|
66
|
+
// where the env is read, with the shared `expandHome` helper. [LAW:
|
|
67
|
+
// dataflow-not-control-flow] When the env var sets the path, it's the
|
|
68
|
+
// *only* candidate — the precedence chain collapses to one entry.
|
|
69
|
+
return [expandHome(envPath)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const effectiveCwd = cwd ?? process.cwd();
|
|
73
|
+
const xdgConfigHome =
|
|
74
|
+
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
...(projectDir
|
|
78
|
+
? CONFIG_EXTENSIONS.map((ext) =>
|
|
79
|
+
path.join(projectDir, `.cc-candybar.${ext}`),
|
|
80
|
+
)
|
|
81
|
+
: []),
|
|
82
|
+
...CONFIG_EXTENSIONS.map((ext) =>
|
|
83
|
+
path.join(effectiveCwd, `.cc-candybar.${ext}`),
|
|
84
|
+
),
|
|
85
|
+
...CONFIG_EXTENSIONS.map((ext) =>
|
|
86
|
+
path.join(xdgConfigHome, "cc-candybar", `config.${ext}`),
|
|
87
|
+
),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolution order for the user's DSL config file:
|
|
93
|
+
* 1. `configFile` (the CLI `--config <path>` value, already `~`-expanded)
|
|
94
|
+
* 2. $CC_CANDYBAR_CONFIG env var (literal path, `~`-expanded here)
|
|
95
|
+
* 3. `<projectDir>/.cc-candybar.json5`
|
|
96
|
+
* 4. `<projectDir>/.cc-candybar.json`
|
|
97
|
+
* 5. `<cwd>/.cc-candybar.json5`
|
|
98
|
+
* 6. `<cwd>/.cc-candybar.json`
|
|
99
|
+
* 7. `$XDG_CONFIG_HOME/cc-candybar/config.json5`
|
|
100
|
+
* (defaulting to `~/.config/cc-candybar/config.json5`)
|
|
101
|
+
* 8. `$XDG_CONFIG_HOME/cc-candybar/config.json`
|
|
102
|
+
*
|
|
103
|
+
* Returns the first path that exists, or null if none do.
|
|
104
|
+
*
|
|
105
|
+
* [LAW:dataflow-not-control-flow] The locations array is data; the search is
|
|
106
|
+
* `locations.find(fs.existsSync)`. Adding a layer is a new array entry, not a
|
|
107
|
+
* new branch. Extension support is a property of the candidate list, not the
|
|
108
|
+
* search.
|
|
109
|
+
*
|
|
110
|
+
* [LAW:single-enforcer] Built on top of `dslConfigCandidatePaths` — the
|
|
111
|
+
* precedence list lives in one place.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveDslConfigPath(
|
|
114
|
+
projectDir?: string,
|
|
115
|
+
cwd?: string,
|
|
116
|
+
configFile?: string,
|
|
117
|
+
): string | null {
|
|
118
|
+
return (
|
|
119
|
+
dslConfigCandidatePaths(projectDir, cwd, configFile).find(fs.existsSync) ??
|
|
120
|
+
null
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Detect same-location extension collisions: any location where BOTH
|
|
126
|
+
* `<base>.json5` and `<base>.json` exist simultaneously. The resolver picks
|
|
127
|
+
* .json5 (documented format wins), but the user almost certainly didn't
|
|
128
|
+
* intend to keep two; the duplicate is dead weight that will drift.
|
|
129
|
+
*
|
|
130
|
+
* Returns a human-readable warning naming the conflicting files, or null if
|
|
131
|
+
* no collisions exist. The render path surfaces this through the daemon's
|
|
132
|
+
* diagnostics channel so the user sees it on every render until they remove
|
|
133
|
+
* the duplicate.
|
|
134
|
+
*
|
|
135
|
+
* [LAW:single-enforcer] Consumes `dslConfigCandidatePaths` — same enumerator
|
|
136
|
+
* as the resolver and watcher; collision detection cannot disagree with
|
|
137
|
+
* resolution about which files are candidates.
|
|
138
|
+
*
|
|
139
|
+
* [LAW:dataflow-not-control-flow] Walk candidates, group by parent directory
|
|
140
|
+
* + base name (without extension), find groups with size > 1 whose members
|
|
141
|
+
* all exist. No special-case branches per extension.
|
|
142
|
+
*/
|
|
143
|
+
export function detectConfigCollisions(
|
|
144
|
+
projectDir?: string,
|
|
145
|
+
cwd?: string,
|
|
146
|
+
): string | null {
|
|
147
|
+
const candidates = dslConfigCandidatePaths(projectDir, cwd);
|
|
148
|
+
// [LAW:dataflow-not-control-flow] Dedupe candidates by full path first.
|
|
149
|
+
// When projectDir === cwd (a very common case — the daemon often resolves
|
|
150
|
+
// both from the same hook payload), the enumerator yields the same path
|
|
151
|
+
// at both precedence levels. That is a structural duplicate of *position
|
|
152
|
+
// in the precedence list*, not a same-location duplicate of *files on
|
|
153
|
+
// disk*. The latter is what collision detection is for; the former is
|
|
154
|
+
// noise that would fire a false positive.
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
const uniqueExisting: string[] = [];
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
if (seen.has(candidate)) continue;
|
|
159
|
+
seen.add(candidate);
|
|
160
|
+
if (!fs.existsSync(candidate)) continue;
|
|
161
|
+
uniqueExisting.push(candidate);
|
|
162
|
+
}
|
|
163
|
+
// Group by (dir + base-without-extension). A group with > 1 existing
|
|
164
|
+
// member is a collision at that logical location.
|
|
165
|
+
const groups = new Map<string, string[]>();
|
|
166
|
+
for (const candidate of uniqueExisting) {
|
|
167
|
+
const dir = path.dirname(candidate);
|
|
168
|
+
const base = path.basename(candidate).replace(/\.(json5|json)$/, "");
|
|
169
|
+
const key = path.join(dir, base);
|
|
170
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
171
|
+
groups.get(key)!.push(candidate);
|
|
172
|
+
}
|
|
173
|
+
const collisions = [...groups.values()].filter((g) => g.length > 1);
|
|
174
|
+
if (collisions.length === 0) return null;
|
|
175
|
+
// Stable, parseable message. The first file in each group is the .json5
|
|
176
|
+
// (the one that wins); the rest are the shadowed siblings.
|
|
177
|
+
const lines = collisions.map((g) => {
|
|
178
|
+
const [winner, ...shadowed] = g;
|
|
179
|
+
return `${winner} shadows ${shadowed.join(", ")}`;
|
|
180
|
+
});
|
|
181
|
+
return `config-extension collision: ${lines.join("; ")} — remove the duplicate`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] The JSON Schema emitter — the SECOND interpreter over
|
|
2
|
+
// the declarative loader schemas. `validateConfig` composes the per-module
|
|
3
|
+
// `validate*` functions to validate at runtime; `emitConfigSchema` composes the
|
|
4
|
+
// per-module `*Json` functions to derive the editor-facing JSON Schema. Both read
|
|
5
|
+
// the SAME module-private schema declarations (GLOBALS_SCHEMA, VARIABLE_SCHEMA,
|
|
6
|
+
// SEGMENT_SCHEMA, CACHE_SCHEMA, SET_ARMS, the A-grammar layout grammar), so the
|
|
7
|
+
// published schema and the runtime validator can never describe a different grammar.
|
|
8
|
+
//
|
|
9
|
+
// What a JSON Schema still cannot express (by construction): cross-field
|
|
10
|
+
// refinements (min<max, by≠0, input default matches type), palette-name
|
|
11
|
+
// membership, duration FORMAT, and cross-references between segments/variables/
|
|
12
|
+
// cycles. Those stay SEMANTIC checks the loader carries — schema = shape, lint =
|
|
13
|
+
// meaning, the same complementary boundary `config-schema.test.ts` pins.
|
|
14
|
+
|
|
15
|
+
import { globalsJson } from "./globals.js";
|
|
16
|
+
import { variablesMapJson } from "./variables.js";
|
|
17
|
+
import { segmentsJson } from "./segments.js";
|
|
18
|
+
import { actionsJson } from "./actions.js";
|
|
19
|
+
import {
|
|
20
|
+
layoutNodeJson,
|
|
21
|
+
LAYOUT_NODE_DEF_NAME,
|
|
22
|
+
LAYOUT_NODE_REF,
|
|
23
|
+
} from "./layout.js";
|
|
24
|
+
import type { JsonNode } from "./validate-core.js";
|
|
25
|
+
|
|
26
|
+
// [LAW:one-source-of-truth] The stable published identity. The committed artifact
|
|
27
|
+
// is self-identifying at this URL so an editor loading it via `$schema` resolves.
|
|
28
|
+
export const SCHEMA_ID =
|
|
29
|
+
"https://raw.githubusercontent.com/promptctl/cc-candybar/main/schema/cc-candybar.schema.json";
|
|
30
|
+
|
|
31
|
+
// [LAW:dataflow-not-control-flow] The RawDslConfig schema: every top-level key is
|
|
32
|
+
// optional (a user file declares only what differs from the bundled default), so
|
|
33
|
+
// the object carries no `required`. Each property is one module's emitted shape —
|
|
34
|
+
// the same composition `validateConfig` performs over the validators. `root`
|
|
35
|
+
// references the LayoutNode definition that closes the node recursion via `$ref`.
|
|
36
|
+
export function emitConfigSchema(): JsonNode {
|
|
37
|
+
return {
|
|
38
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
39
|
+
$id: SCHEMA_ID,
|
|
40
|
+
title: "cc-candybar config (.cc-candybar.json5)",
|
|
41
|
+
type: "object",
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
properties: {
|
|
44
|
+
globals: globalsJson(),
|
|
45
|
+
variables: variablesMapJson(),
|
|
46
|
+
segments: segmentsJson(),
|
|
47
|
+
root: { $ref: LAYOUT_NODE_REF },
|
|
48
|
+
actions: actionsJson(),
|
|
49
|
+
helpers: { type: "object", additionalProperties: { type: "string" } },
|
|
50
|
+
},
|
|
51
|
+
definitions: {
|
|
52
|
+
[LAYOUT_NODE_DEF_NAME]: layoutNodeJson(),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// [LAW:single-enforcer] One serialization, shared by `gen:schema` (writes the
|
|
58
|
+
// committed artifact) and `check:schema` (byte-diffs against it) so the two can
|
|
59
|
+
// never disagree on how the schema is produced. Trailing newline + 2-space indent
|
|
60
|
+
// match the committed file's format.
|
|
61
|
+
export function serializeConfigSchema(): string {
|
|
62
|
+
return JSON.stringify(emitConfigSchema(), null, 2) + "\n";
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The globals schema: a fixed set of string fields
|
|
2
|
+
// plus a validated palette name, declared as DATA and interpreted by the record
|
|
3
|
+
// engine. This file changes when a global default field is added or removed —
|
|
4
|
+
// add a key to GLOBALS_SCHEMA and Globals; the engine does the rest.
|
|
5
|
+
|
|
6
|
+
import { type Globals } from "../dsl-types.js";
|
|
7
|
+
import {
|
|
8
|
+
optionalStringSpec,
|
|
9
|
+
paletteSpec,
|
|
10
|
+
record,
|
|
11
|
+
recordJson,
|
|
12
|
+
type JsonNode,
|
|
13
|
+
type RecordSchema,
|
|
14
|
+
type ValidateCtx,
|
|
15
|
+
} from "./validate-core.js";
|
|
16
|
+
|
|
17
|
+
const GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
18
|
+
noun: "globals key",
|
|
19
|
+
fields: {
|
|
20
|
+
default_bg: optionalStringSpec(),
|
|
21
|
+
default_fg: optionalStringSpec(),
|
|
22
|
+
default_empty_value: optionalStringSpec(),
|
|
23
|
+
default_separator: optionalStringSpec(),
|
|
24
|
+
default_truncate_marker: optionalStringSpec(),
|
|
25
|
+
palette: paletteSpec(),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// An absent globals block is the empty default (no issue); a non-object is a
|
|
30
|
+
// reported error that recovers to the empty default, since parseDslConfig throws
|
|
31
|
+
// once any issue exists so the recovery value never renders [LAW:no-silent-failure].
|
|
32
|
+
export function validateGlobals(ctx: ValidateCtx, raw: unknown): Globals {
|
|
33
|
+
if (raw === undefined) return {};
|
|
34
|
+
return record(ctx, GLOBALS_SCHEMA, "globals", raw) ?? {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
|
|
38
|
+
// the validator interprets — `globals` emit is `recordJson(GLOBALS_SCHEMA)`,
|
|
39
|
+
// symmetric to `validateGlobals` calling `record(GLOBALS_SCHEMA)`.
|
|
40
|
+
export function globalsJson(): JsonNode {
|
|
41
|
+
return recordJson(GLOBALS_SCHEMA);
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The helpers schema is the simplest possible: a
|
|
2
|
+
// record of name → template-body STRING. Each value is a Go-template source the
|
|
3
|
+
// renderer compiles into a `{{ define }}` block; whether the body PARSES (and
|
|
4
|
+
// whether a `{{ template "name" }}` reference resolves) is a render-time concern
|
|
5
|
+
// (registerDslConfig parses the preamble and throws a per-helper diagnostic).
|
|
6
|
+
// This file changes only if the helper authoring shape changes.
|
|
7
|
+
|
|
8
|
+
import { findKeyLine } from "./diagnostics.js";
|
|
9
|
+
import {
|
|
10
|
+
describeType,
|
|
11
|
+
describeValue,
|
|
12
|
+
isPlainObject,
|
|
13
|
+
type ValidateCtx,
|
|
14
|
+
} from "./validate-core.js";
|
|
15
|
+
|
|
16
|
+
// [LAW:single-enforcer] Structural validation of the `helpers` block: an object
|
|
17
|
+
// whose every value is a string template body. Null-prototype record so a helper
|
|
18
|
+
// named "__proto__"/"constructor" is an ordinary own property, matching actions.
|
|
19
|
+
export function validateHelpers(
|
|
20
|
+
ctx: ValidateCtx,
|
|
21
|
+
raw: unknown,
|
|
22
|
+
): Record<string, string> {
|
|
23
|
+
if (raw === undefined) return {};
|
|
24
|
+
if (!isPlainObject(raw)) {
|
|
25
|
+
ctx.issues.push({
|
|
26
|
+
path: "helpers",
|
|
27
|
+
message: `helpers must be an object, got ${describeType(raw)}`,
|
|
28
|
+
line: findKeyLine(ctx.source, ["helpers"]),
|
|
29
|
+
});
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
const out: Record<string, string> = Object.create(null) as Record<
|
|
33
|
+
string,
|
|
34
|
+
string
|
|
35
|
+
>;
|
|
36
|
+
for (const [name, body] of Object.entries(raw)) {
|
|
37
|
+
if (typeof body !== "string") {
|
|
38
|
+
ctx.issues.push({
|
|
39
|
+
path: `helpers.${name}`,
|
|
40
|
+
message: `helpers.${name} must be a string template body, got ${describeValue(body)}`,
|
|
41
|
+
line: findKeyLine(ctx.source, ["helpers", name]),
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
out[name] = body;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|