whale-igniter 1.1.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 +275 -0
- package/dist/analyzer/imports.js +88 -0
- package/dist/analyzer/insights.js +276 -0
- package/dist/commands/add.js +36 -0
- package/dist/commands/adopt.js +180 -0
- package/dist/commands/adoptReview.js +267 -0
- package/dist/commands/component.js +93 -0
- package/dist/commands/createComponent.js +207 -0
- package/dist/commands/decision.js +98 -0
- package/dist/commands/docs.js +34 -0
- package/dist/commands/ignite.js +212 -0
- package/dist/commands/init.js +66 -0
- package/dist/commands/insights.js +123 -0
- package/dist/commands/mcp.js +106 -0
- package/dist/commands/refine.js +36 -0
- package/dist/commands/selene.js +516 -0
- package/dist/commands/sync.js +43 -0
- package/dist/commands/validate.js +48 -0
- package/dist/commands/watch.js +150 -0
- package/dist/commands/wiki.js +21 -0
- package/dist/generators/markdownGenerator.js +112 -0
- package/dist/generators/reportGenerator.js +50 -0
- package/dist/generators/wikiGenerator.js +365 -0
- package/dist/index.js +213 -0
- package/dist/mcp/server.js +404 -0
- package/dist/scanner/componentScanner.js +522 -0
- package/dist/scanner/foundationInferrer.js +174 -0
- package/dist/scanner/tailwindMapper.js +58 -0
- package/dist/scanner/tailwindScanner.js +186 -0
- package/dist/selene/apiClient.js +168 -0
- package/dist/selene/cache.js +68 -0
- package/dist/selene/clipboard.js +56 -0
- package/dist/selene/promptBuilder.js +229 -0
- package/dist/selene/providers.js +67 -0
- package/dist/selene/responseParser.js +149 -0
- package/dist/ui/atoms.js +30 -0
- package/dist/ui/blocks.js +208 -0
- package/dist/ui/capabilities.js +64 -0
- package/dist/ui/index.js +13 -0
- package/dist/ui/symbols.js +41 -0
- package/dist/ui/theme.js +78 -0
- package/dist/utils/components.js +40 -0
- package/dist/utils/config.js +31 -0
- package/dist/utils/decisions.js +32 -0
- package/dist/utils/paths.js +4 -0
- package/dist/utils/proposals.js +61 -0
- package/dist/utils/refinements.js +81 -0
- package/dist/utils/registry.js +45 -0
- package/dist/utils/writeJson.js +6 -0
- package/dist/validators/cssValidator.js +204 -0
- package/dist/version.js +1 -0
- package/docs/ROADMAP.md +206 -0
- package/package.json +76 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selene prompt composer.
|
|
3
|
+
*
|
|
4
|
+
* Every prompt has the same shape:
|
|
5
|
+
*
|
|
6
|
+
* 1. Identity — who Whale is, what this file is for
|
|
7
|
+
* 2. Project context — foundations, conventions, active decisions and
|
|
8
|
+
* refinements (a compressed view, not the full wiki)
|
|
9
|
+
* 3. Task — what the LLM should do
|
|
10
|
+
* 4. Input — the code/data the task operates on
|
|
11
|
+
* 5. Output format — strict, so we can parse the response back
|
|
12
|
+
*
|
|
13
|
+
* The fifth section is the one most people skip. Without it, the LLM
|
|
14
|
+
* returns markdown prose that can't be machine-parsed, so the round
|
|
15
|
+
* trip into `whale selene apply` becomes manual. We're aggressive about
|
|
16
|
+
* specifying the output shape — usually a fenced JSON block with a
|
|
17
|
+
* known schema.
|
|
18
|
+
*/
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Shared sections
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function renderIdentity() {
|
|
23
|
+
return [
|
|
24
|
+
"You are helping maintain operational context for a project managed by Whale Igniter.",
|
|
25
|
+
"Whale stores design-system foundations, decisions, conventions, and a component",
|
|
26
|
+
"catalog in machine-readable form so AI agents understand the project without",
|
|
27
|
+
"re-explanation. Your output will be consumed by a CLI parser, so follow the",
|
|
28
|
+
"Output Format section exactly."
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
31
|
+
function renderProjectContext(ctx) {
|
|
32
|
+
const lines = ["## Project context"];
|
|
33
|
+
const c = ctx.config;
|
|
34
|
+
lines.push("");
|
|
35
|
+
lines.push("**Project**");
|
|
36
|
+
lines.push(`- Name: ${c.projectName ?? "(unset)"}`);
|
|
37
|
+
lines.push(`- Type: ${c.projectType ?? "unspecified"}`);
|
|
38
|
+
lines.push(`- Stack: ${c.stack ?? "css"}`);
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push("**Foundations**");
|
|
41
|
+
lines.push(`- Grid: ${c.foundations?.grid ?? 8}px (all spacing must be a multiple)`);
|
|
42
|
+
lines.push(`- Control radius: ${c.foundations?.radius?.control ?? 4}px (buttons, inputs)`);
|
|
43
|
+
lines.push(`- Container radius: ${c.foundations?.radius?.container ?? 8}px (cards, modals)`);
|
|
44
|
+
// Active decisions — show titles only to keep tokens down. Full text
|
|
45
|
+
// is available in intelligence/decisions.json if the model needs it.
|
|
46
|
+
const active = ctx.decisions.filter((d) => d.status === "active");
|
|
47
|
+
if (active.length > 0) {
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push("**Active decisions** (most recent first)");
|
|
50
|
+
const recent = [...active].sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 8);
|
|
51
|
+
for (const d of recent) {
|
|
52
|
+
lines.push(`- [${d.category}] ${d.title}`);
|
|
53
|
+
}
|
|
54
|
+
if (active.length > recent.length) {
|
|
55
|
+
lines.push(`- … and ${active.length - recent.length} more in intelligence/decisions.json`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (ctx.refinements.length > 0) {
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push("**Approved exceptions (refinements)**");
|
|
61
|
+
const recent = [...ctx.refinements].sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 6);
|
|
62
|
+
for (const r of recent) {
|
|
63
|
+
const scope = r.scope?.issueType ? ` _(${r.scope.issueType})_` : "";
|
|
64
|
+
lines.push(`- ${r.note}${scope}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (ctx.components.length > 0) {
|
|
68
|
+
lines.push("");
|
|
69
|
+
lines.push("**Existing components in catalog**");
|
|
70
|
+
const names = ctx.components.map((c) => c.name).sort().slice(0, 30);
|
|
71
|
+
lines.push(names.join(", ") + (ctx.components.length > 30 ? `, … (${ctx.components.length} total)` : ""));
|
|
72
|
+
}
|
|
73
|
+
lines.push("");
|
|
74
|
+
lines.push("**Universal rules**");
|
|
75
|
+
lines.push(`- Spacing is a multiple of ${c.foundations?.grid ?? 8}px.`);
|
|
76
|
+
lines.push("- Use semantic tokens over raw color values.");
|
|
77
|
+
lines.push("- Every interactive element needs a visible :focus-visible state.");
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
function fence(label, content) {
|
|
81
|
+
return "```" + label + "\n" + content + "\n```";
|
|
82
|
+
}
|
|
83
|
+
export function buildDescribePrompt(ctx, input) {
|
|
84
|
+
const sections = [];
|
|
85
|
+
sections.push("# Whale Selene — describe component");
|
|
86
|
+
sections.push("");
|
|
87
|
+
sections.push(renderIdentity());
|
|
88
|
+
sections.push("");
|
|
89
|
+
sections.push(renderProjectContext(ctx));
|
|
90
|
+
sections.push("");
|
|
91
|
+
sections.push("## Task");
|
|
92
|
+
sections.push("");
|
|
93
|
+
sections.push(`Write a catalog entry for the component named \`${input.componentName}\` (file: \`${input.filePath}\`).`);
|
|
94
|
+
sections.push("");
|
|
95
|
+
sections.push("Read the source below and produce:");
|
|
96
|
+
sections.push("- A one-sentence `description` (≤ 140 chars) suitable for a component catalog.");
|
|
97
|
+
sections.push("- A `category` (one of: form, navigation, feedback, surface, layout, data, overlay) — pick the best fit.");
|
|
98
|
+
sections.push("- `variants` — the visual variants accepted as props (e.g. primary, secondary). Empty array if none.");
|
|
99
|
+
sections.push("- `states` — interactive states styled in the component (e.g. hover, focus, disabled). Empty if none.");
|
|
100
|
+
sections.push("- `tokens` — design tokens / Tailwind classes the component depends on (limit to the 8 most central).");
|
|
101
|
+
sections.push("");
|
|
102
|
+
if (input.existingEntry) {
|
|
103
|
+
sections.push("**Existing catalog entry for this component (refine, don't restate):**");
|
|
104
|
+
sections.push(fence("json", JSON.stringify(input.existingEntry, null, 2)));
|
|
105
|
+
sections.push("");
|
|
106
|
+
}
|
|
107
|
+
sections.push("## Input — source");
|
|
108
|
+
sections.push("");
|
|
109
|
+
sections.push(fence(extLang(input.filePath), input.fileContents));
|
|
110
|
+
sections.push("");
|
|
111
|
+
sections.push("## Output format");
|
|
112
|
+
sections.push("");
|
|
113
|
+
sections.push("Return a single fenced JSON block with the exact shape below. Nothing before or after — no preamble, no explanation.");
|
|
114
|
+
sections.push("");
|
|
115
|
+
sections.push(fence("json", JSON.stringify({
|
|
116
|
+
name: input.componentName,
|
|
117
|
+
description: "string ≤ 140 chars",
|
|
118
|
+
category: "form | navigation | feedback | surface | layout | data | overlay",
|
|
119
|
+
variants: ["string"],
|
|
120
|
+
states: ["string"],
|
|
121
|
+
tokens: ["string"]
|
|
122
|
+
}, null, 2)));
|
|
123
|
+
return sections.join("\n");
|
|
124
|
+
}
|
|
125
|
+
export function buildAuditPrompt(ctx, input) {
|
|
126
|
+
const sections = [];
|
|
127
|
+
sections.push("# Whale Selene — audit file");
|
|
128
|
+
sections.push("");
|
|
129
|
+
sections.push(renderIdentity());
|
|
130
|
+
sections.push("");
|
|
131
|
+
sections.push(renderProjectContext(ctx));
|
|
132
|
+
sections.push("");
|
|
133
|
+
sections.push("## Task");
|
|
134
|
+
sections.push("");
|
|
135
|
+
sections.push(`Audit the file below against this project's foundations, conventions, and active decisions.`);
|
|
136
|
+
sections.push("");
|
|
137
|
+
sections.push("For each issue you find, produce one entry. Be specific — quote the exact line if you can.");
|
|
138
|
+
sections.push("Do NOT flag issues that are already covered by an approved refinement listed above.");
|
|
139
|
+
sections.push("");
|
|
140
|
+
sections.push("## Input — file");
|
|
141
|
+
sections.push("");
|
|
142
|
+
sections.push(`\`${input.filePath}\``);
|
|
143
|
+
sections.push("");
|
|
144
|
+
sections.push(fence(extLang(input.filePath), input.fileContents));
|
|
145
|
+
sections.push("");
|
|
146
|
+
sections.push("## Output format");
|
|
147
|
+
sections.push("");
|
|
148
|
+
sections.push("Return a single fenced JSON block. Each issue has: `severity` (critical|warning|info), `rule` (one of: grid, radius, color, focus, naming, accessibility, decision, other), `line` (number, optional), `message` (one sentence), `suggestion` (concrete fix).");
|
|
149
|
+
sections.push("");
|
|
150
|
+
sections.push(fence("json", JSON.stringify({
|
|
151
|
+
file: input.filePath,
|
|
152
|
+
issues: [
|
|
153
|
+
{ severity: "warning", rule: "grid", line: 12, message: "...", suggestion: "..." }
|
|
154
|
+
]
|
|
155
|
+
}, null, 2)));
|
|
156
|
+
sections.push("");
|
|
157
|
+
sections.push("If the file is fully compliant, return `{ \"file\": \"...\", \"issues\": [] }`.");
|
|
158
|
+
return sections.join("\n");
|
|
159
|
+
}
|
|
160
|
+
export function buildSuggestPrompt(ctx, input = {}) {
|
|
161
|
+
const sections = [];
|
|
162
|
+
sections.push("# Whale Selene — suggest patterns");
|
|
163
|
+
sections.push("");
|
|
164
|
+
sections.push(renderIdentity());
|
|
165
|
+
sections.push("");
|
|
166
|
+
sections.push(renderProjectContext(ctx));
|
|
167
|
+
sections.push("");
|
|
168
|
+
sections.push("## Task");
|
|
169
|
+
sections.push("");
|
|
170
|
+
sections.push("Looking at the project context above, suggest up to five patterns, conventions, or decisions worth recording.");
|
|
171
|
+
sections.push("");
|
|
172
|
+
sections.push("Good suggestions:");
|
|
173
|
+
sections.push("- Consolidate repeated refinements into a foundation change.");
|
|
174
|
+
sections.push("- Promote an implicit convention into a recorded decision.");
|
|
175
|
+
sections.push("- Flag a gap between what the codebase does and what's documented.");
|
|
176
|
+
sections.push("");
|
|
177
|
+
sections.push("Bad suggestions:");
|
|
178
|
+
sections.push("- Generic best practices not tied to evidence above.");
|
|
179
|
+
sections.push("- Restatements of rules already listed.");
|
|
180
|
+
sections.push("");
|
|
181
|
+
if (input.focus && input.focus !== "all") {
|
|
182
|
+
sections.push(`**Focus area:** ${input.focus}`);
|
|
183
|
+
sections.push("");
|
|
184
|
+
}
|
|
185
|
+
sections.push("## Output format");
|
|
186
|
+
sections.push("");
|
|
187
|
+
sections.push("Return a single fenced JSON block. Each suggestion has: `title` (≤ 80 chars), `kind` (decision|refinement|foundation|cleanup), `category` (architecture|design-system|product|tooling|convention), `rationale` (1-2 sentences citing evidence), `proposed_text` (the actual decision/refinement text to record).");
|
|
188
|
+
sections.push("");
|
|
189
|
+
sections.push(fence("json", JSON.stringify({
|
|
190
|
+
suggestions: [
|
|
191
|
+
{
|
|
192
|
+
title: "string",
|
|
193
|
+
kind: "decision",
|
|
194
|
+
category: "design-system",
|
|
195
|
+
rationale: "string",
|
|
196
|
+
proposed_text: "string"
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
}, null, 2)));
|
|
200
|
+
return sections.join("\n");
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Helpers
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
function extLang(filePath) {
|
|
206
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
207
|
+
switch (ext) {
|
|
208
|
+
case "tsx":
|
|
209
|
+
case "ts":
|
|
210
|
+
return "typescript";
|
|
211
|
+
case "jsx":
|
|
212
|
+
case "js":
|
|
213
|
+
return "javascript";
|
|
214
|
+
case "css":
|
|
215
|
+
return "css";
|
|
216
|
+
case "scss":
|
|
217
|
+
return "scss";
|
|
218
|
+
case "vue":
|
|
219
|
+
return "vue";
|
|
220
|
+
case "svelte":
|
|
221
|
+
return "svelte";
|
|
222
|
+
case "json":
|
|
223
|
+
return "json";
|
|
224
|
+
case "md":
|
|
225
|
+
return "markdown";
|
|
226
|
+
default:
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection for Selene.
|
|
3
|
+
*
|
|
4
|
+
* Lookup order:
|
|
5
|
+
* 1. Explicit `--provider <name>` (handled by the command layer).
|
|
6
|
+
* 2. `selene.provider` in whale.config.json.
|
|
7
|
+
* 3. First key found in env: ANTHROPIC_API_KEY, then OPENAI_API_KEY.
|
|
8
|
+
*
|
|
9
|
+
* If nothing matches, we return `null` — the caller should fall back
|
|
10
|
+
* to prompt mode. We never throw for "no key" because that's the
|
|
11
|
+
* normal v0.10 path; absence of credentials is supported on purpose.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_MODELS = {
|
|
14
|
+
// Conservative defaults — newest "stable" public models as of early 2026.
|
|
15
|
+
// The user can override via whale.config.json → selene.model.
|
|
16
|
+
anthropic: "claude-sonnet-4-6",
|
|
17
|
+
openai: "gpt-4.1-mini"
|
|
18
|
+
};
|
|
19
|
+
const ENV_KEYS = {
|
|
20
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
21
|
+
openai: "OPENAI_API_KEY"
|
|
22
|
+
};
|
|
23
|
+
export function resolveProvider(config, options = {}) {
|
|
24
|
+
// Build the ordered list of providers to try.
|
|
25
|
+
const order = [];
|
|
26
|
+
if (options.force)
|
|
27
|
+
order.push(options.force);
|
|
28
|
+
if (config.selene?.provider && !order.includes(config.selene.provider)) {
|
|
29
|
+
order.push(config.selene.provider);
|
|
30
|
+
}
|
|
31
|
+
// Fill remaining providers as fallback so that, when force isn't set,
|
|
32
|
+
// we'll happily use whatever key the user has.
|
|
33
|
+
for (const p of ["anthropic", "openai"]) {
|
|
34
|
+
if (!order.includes(p))
|
|
35
|
+
order.push(p);
|
|
36
|
+
}
|
|
37
|
+
for (const provider of order) {
|
|
38
|
+
const envName = ENV_KEYS[provider];
|
|
39
|
+
const fromEnv = process.env[envName];
|
|
40
|
+
if (fromEnv && fromEnv.trim().length > 0) {
|
|
41
|
+
return {
|
|
42
|
+
provider,
|
|
43
|
+
apiKey: fromEnv.trim(),
|
|
44
|
+
model: options.modelOverride ?? config.selene?.model ?? DEFAULT_MODELS[provider],
|
|
45
|
+
source: "env"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Friendly description of what's available — useful for `whale selene status`
|
|
53
|
+
* and for error messages when the user explicitly forced a provider.
|
|
54
|
+
*/
|
|
55
|
+
export function describeProviderState() {
|
|
56
|
+
return ["anthropic", "openai"].map((provider) => {
|
|
57
|
+
const envName = ENV_KEYS[provider];
|
|
58
|
+
return {
|
|
59
|
+
provider,
|
|
60
|
+
envName,
|
|
61
|
+
available: !!process.env[envName] && process.env[envName].trim().length > 0
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function defaultModelFor(provider) {
|
|
66
|
+
return DEFAULT_MODELS[provider];
|
|
67
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse LLM responses pasted back by the user.
|
|
3
|
+
*
|
|
4
|
+
* Real-world responses are messy: a preamble paragraph, then a fenced
|
|
5
|
+
* code block, then more prose. Sometimes multiple JSON blocks. We
|
|
6
|
+
* extract the first one that parses AND matches the expected shape,
|
|
7
|
+
* silently skipping the rest.
|
|
8
|
+
*
|
|
9
|
+
* If nothing parses, we return a structured error so the command can
|
|
10
|
+
* tell the user what went wrong (e.g. "I found a JSON block but it
|
|
11
|
+
* didn't match the expected schema").
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Extract every fenced code block from a markdown-ish string. We accept
|
|
15
|
+
* ```json, ```JSON, ```, and treat them all the same — we'll parse and
|
|
16
|
+
* see. The regex deliberately uses [\s\S] not . so that newlines match.
|
|
17
|
+
*/
|
|
18
|
+
function extractFencedBlocks(text) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const re = /```(?:[a-zA-Z]+)?\s*\n([\s\S]*?)```/g;
|
|
21
|
+
let m;
|
|
22
|
+
while ((m = re.exec(text)) !== null) {
|
|
23
|
+
out.push(m[1]);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Some users paste raw JSON without fences. Try to recognize a JSON
|
|
29
|
+
* payload by hunting for the outermost balanced brace pair.
|
|
30
|
+
*/
|
|
31
|
+
function extractBareJsonPayload(text) {
|
|
32
|
+
const start = text.indexOf("{");
|
|
33
|
+
if (start === -1)
|
|
34
|
+
return null;
|
|
35
|
+
let depth = 0;
|
|
36
|
+
for (let i = start; i < text.length; i += 1) {
|
|
37
|
+
const ch = text[i];
|
|
38
|
+
if (ch === "{")
|
|
39
|
+
depth += 1;
|
|
40
|
+
else if (ch === "}") {
|
|
41
|
+
depth -= 1;
|
|
42
|
+
if (depth === 0)
|
|
43
|
+
return text.slice(start, i + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function tryParse(payload) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(payload);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function parseJsonResponse(text, validate) {
|
|
57
|
+
const candidates = [...extractFencedBlocks(text)];
|
|
58
|
+
const bare = extractBareJsonPayload(text);
|
|
59
|
+
if (bare && !candidates.includes(bare))
|
|
60
|
+
candidates.push(bare);
|
|
61
|
+
if (candidates.length === 0) {
|
|
62
|
+
return { ok: false, reason: "No JSON found in the response." };
|
|
63
|
+
}
|
|
64
|
+
let lastSyntax = null;
|
|
65
|
+
let lastShape = null;
|
|
66
|
+
for (const c of candidates) {
|
|
67
|
+
const parsed = tryParse(c);
|
|
68
|
+
if (parsed === null) {
|
|
69
|
+
lastSyntax = "Found a code block but couldn't parse it as JSON.";
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (validate(parsed)) {
|
|
73
|
+
return { ok: true, value: parsed };
|
|
74
|
+
}
|
|
75
|
+
lastShape = "Parsed JSON but its shape didn't match the expected schema.";
|
|
76
|
+
}
|
|
77
|
+
return { ok: false, reason: lastShape ?? lastSyntax ?? "No usable JSON found." };
|
|
78
|
+
}
|
|
79
|
+
export function isDescribeResponse(v) {
|
|
80
|
+
if (!v || typeof v !== "object")
|
|
81
|
+
return false;
|
|
82
|
+
const o = v;
|
|
83
|
+
if (typeof o.name !== "string" || o.name.length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
if (typeof o.description !== "string" || o.description.length === 0)
|
|
86
|
+
return false;
|
|
87
|
+
if (o.category !== undefined && typeof o.category !== "string")
|
|
88
|
+
return false;
|
|
89
|
+
if (o.variants !== undefined && !isStringArray(o.variants))
|
|
90
|
+
return false;
|
|
91
|
+
if (o.states !== undefined && !isStringArray(o.states))
|
|
92
|
+
return false;
|
|
93
|
+
if (o.tokens !== undefined && !isStringArray(o.tokens))
|
|
94
|
+
return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
export function isAuditResponse(v) {
|
|
98
|
+
if (!v || typeof v !== "object")
|
|
99
|
+
return false;
|
|
100
|
+
const o = v;
|
|
101
|
+
if (typeof o.file !== "string")
|
|
102
|
+
return false;
|
|
103
|
+
if (!Array.isArray(o.issues))
|
|
104
|
+
return false;
|
|
105
|
+
for (const i of o.issues) {
|
|
106
|
+
if (!i || typeof i !== "object")
|
|
107
|
+
return false;
|
|
108
|
+
const iss = i;
|
|
109
|
+
if (iss.severity !== "critical" && iss.severity !== "warning" && iss.severity !== "info")
|
|
110
|
+
return false;
|
|
111
|
+
if (typeof iss.rule !== "string")
|
|
112
|
+
return false;
|
|
113
|
+
if (typeof iss.message !== "string")
|
|
114
|
+
return false;
|
|
115
|
+
if (iss.line !== undefined && typeof iss.line !== "number")
|
|
116
|
+
return false;
|
|
117
|
+
if (iss.suggestion !== undefined && typeof iss.suggestion !== "string")
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
export function isSuggestResponse(v) {
|
|
123
|
+
if (!v || typeof v !== "object")
|
|
124
|
+
return false;
|
|
125
|
+
const o = v;
|
|
126
|
+
if (!Array.isArray(o.suggestions))
|
|
127
|
+
return false;
|
|
128
|
+
const validKinds = new Set(["decision", "refinement", "foundation", "cleanup"]);
|
|
129
|
+
const validCats = new Set(["architecture", "design-system", "product", "tooling", "convention"]);
|
|
130
|
+
for (const s of o.suggestions) {
|
|
131
|
+
if (!s || typeof s !== "object")
|
|
132
|
+
return false;
|
|
133
|
+
const sg = s;
|
|
134
|
+
if (typeof sg.title !== "string" || sg.title.length === 0)
|
|
135
|
+
return false;
|
|
136
|
+
if (typeof sg.kind !== "string" || !validKinds.has(sg.kind))
|
|
137
|
+
return false;
|
|
138
|
+
if (typeof sg.category !== "string" || !validCats.has(sg.category))
|
|
139
|
+
return false;
|
|
140
|
+
if (typeof sg.rationale !== "string")
|
|
141
|
+
return false;
|
|
142
|
+
if (typeof sg.proposed_text !== "string" || sg.proposed_text.length === 0)
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
function isStringArray(v) {
|
|
148
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
149
|
+
}
|
package/dist/ui/atoms.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atoms — thin wrappers over theme tokens. Commands import these
|
|
3
|
+
* instead of chalk directly.
|
|
4
|
+
*
|
|
5
|
+
* Two reasons:
|
|
6
|
+
* 1. Decouples commands from chalk so the theme can change later
|
|
7
|
+
* without grep-and-replace across the codebase.
|
|
8
|
+
* 2. Lets us inject behaviour later (e.g. strip colors when
|
|
9
|
+
* NO_COLOR is set, or when piping). The atoms are the choke point.
|
|
10
|
+
*/
|
|
11
|
+
import { theme } from "./theme.js";
|
|
12
|
+
export const accent = (text) => theme().identity.accent(text);
|
|
13
|
+
export const emphasis = (text) => theme().identity.emphasis(text);
|
|
14
|
+
export const success = (text) => theme().semantic.success(text);
|
|
15
|
+
export const warning = (text) => theme().semantic.warning(text);
|
|
16
|
+
export const danger = (text) => theme().semantic.danger(text);
|
|
17
|
+
export const info = (text) => theme().semantic.info(text);
|
|
18
|
+
export const muted = (text) => theme().semantic.muted(text);
|
|
19
|
+
export const code = (text) => theme().semantic.code(text);
|
|
20
|
+
export const path = (text) => theme().semantic.path(text);
|
|
21
|
+
export const heading = (text) => theme().semantic.heading(text);
|
|
22
|
+
export const subheading = (text) => theme().semantic.subheading(text);
|
|
23
|
+
/**
|
|
24
|
+
* Compose styles by wrapping the inner first.
|
|
25
|
+
*
|
|
26
|
+
* Example: `compose(accent, emphasis)("Whale")` → bold cyan "Whale".
|
|
27
|
+
*/
|
|
28
|
+
export function compose(...styles) {
|
|
29
|
+
return (text) => styles.reduceRight((acc, fn) => fn(acc), text);
|
|
30
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks — composable output primitives.
|
|
3
|
+
*
|
|
4
|
+
* Each function returns a *string*, never side-effects. The caller
|
|
5
|
+
* decides where to write it (typically `process.stdout`, but tests
|
|
6
|
+
* and the MCP server need to capture the text instead).
|
|
7
|
+
*
|
|
8
|
+
* Output style guide:
|
|
9
|
+
* - One blank line before and after every block-level element.
|
|
10
|
+
* - Two-space indent for nested content.
|
|
11
|
+
* - Symbols carry semantic weight; color reinforces them.
|
|
12
|
+
* - Headings are bold, not coloured (unless they ARE the brand mark).
|
|
13
|
+
*/
|
|
14
|
+
import { theme } from "./theme.js";
|
|
15
|
+
import { glyph } from "./symbols.js";
|
|
16
|
+
import { accent, emphasis, muted, success, warning, danger, info, path as pathStyle, code as codeStyle, subheading } from "./atoms.js";
|
|
17
|
+
const NL = "\n";
|
|
18
|
+
const INDENT = " ";
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Page-level
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* The opening line of a command. Brand-coloured diamond + title in bold.
|
|
24
|
+
*
|
|
25
|
+
* ◆ Whale Igniter — ignite (opinionated)
|
|
26
|
+
*
|
|
27
|
+
* The optional `subtitle` is rendered dimmer on the same line.
|
|
28
|
+
*/
|
|
29
|
+
export function header(title, subtitle) {
|
|
30
|
+
const left = `${glyph.diamond} ${emphasis(title)}`;
|
|
31
|
+
if (!subtitle)
|
|
32
|
+
return left;
|
|
33
|
+
return `${left} ${muted("— " + subtitle)}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Standalone section label. Use to separate logical chunks within a
|
|
37
|
+
* single command's output.
|
|
38
|
+
*
|
|
39
|
+
* Foundations
|
|
40
|
+
*/
|
|
41
|
+
export function section(label) {
|
|
42
|
+
return emphasis(label);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Sub-section — a small label that introduces a list or table.
|
|
46
|
+
*
|
|
47
|
+
* Active packs:
|
|
48
|
+
*/
|
|
49
|
+
export function subsection(label) {
|
|
50
|
+
return subheading(label + ":");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Visual rule across the terminal width. Used between major blocks
|
|
54
|
+
* when whitespace alone isn't enough. We don't query terminal width
|
|
55
|
+
* — 72 columns is a sane default that matches most diff viewers.
|
|
56
|
+
*/
|
|
57
|
+
export function rule(width = 72) {
|
|
58
|
+
return muted(theme().symbols.rule.repeat(width));
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Inline elements
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Key/value line: `key: value`
|
|
65
|
+
*
|
|
66
|
+
* The key is dim, the value uses the caller's intent.
|
|
67
|
+
* If `keyWidth` is set, the key is padded to that width *before* the colon,
|
|
68
|
+
* so columns align.
|
|
69
|
+
*
|
|
70
|
+
* grid: 8px high
|
|
71
|
+
* radii: ...
|
|
72
|
+
*/
|
|
73
|
+
export function kv(key, value, opts = {}) {
|
|
74
|
+
const keyText = key + ":";
|
|
75
|
+
const padded = opts.keyWidth ? keyText.padEnd(opts.keyWidth + 1) : keyText;
|
|
76
|
+
return `${muted(padded)} ${value}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Indented bullet with the project's bullet glyph.
|
|
80
|
+
*
|
|
81
|
+
* • text here
|
|
82
|
+
*/
|
|
83
|
+
export function bullet(text, level = 0) {
|
|
84
|
+
return INDENT.repeat(level + 1) + glyph.bullet + " " + text;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Indented "dot" bullet — for nested or tertiary lists.
|
|
88
|
+
*
|
|
89
|
+
* · text here
|
|
90
|
+
*/
|
|
91
|
+
export function dot(text, level = 0) {
|
|
92
|
+
return INDENT.repeat(level + 1) + glyph.dot + " " + muted(text);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A "next" hint — directs the user to a follow-up command.
|
|
96
|
+
*
|
|
97
|
+
* → whale adopt review
|
|
98
|
+
*/
|
|
99
|
+
export function next(action) {
|
|
100
|
+
return `${glyph.arrow} ${action}`;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Status lines (single-line statements of outcome)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
export function ok(text) {
|
|
106
|
+
return `${glyph.check} ${text}`;
|
|
107
|
+
}
|
|
108
|
+
export function fail(text) {
|
|
109
|
+
return `${glyph.cross} ${danger(text)}`;
|
|
110
|
+
}
|
|
111
|
+
export function warn(text) {
|
|
112
|
+
return `${glyph.warn} ${warning(text)}`;
|
|
113
|
+
}
|
|
114
|
+
export function note(text) {
|
|
115
|
+
return `${glyph.info} ${muted(text)}`;
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Badges — short coloured tags for inline use
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
const BADGE_PAD = " ";
|
|
121
|
+
function badge(label, style) {
|
|
122
|
+
return style(BADGE_PAD + label + BADGE_PAD);
|
|
123
|
+
}
|
|
124
|
+
export const badges = {
|
|
125
|
+
success: (text) => badge(text, success),
|
|
126
|
+
warning: (text) => badge(text, warning),
|
|
127
|
+
danger: (text) => badge(text, danger),
|
|
128
|
+
info: (text) => badge(text, info),
|
|
129
|
+
muted: (text) => badge(text, muted)
|
|
130
|
+
};
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Containers — multi-line layouts
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
/**
|
|
135
|
+
* Indent a block of text. Common need: rendering a multi-line paragraph
|
|
136
|
+
* inside a section without manually prefixing each line.
|
|
137
|
+
*/
|
|
138
|
+
export function indent(text, level = 1) {
|
|
139
|
+
const prefix = INDENT.repeat(level);
|
|
140
|
+
return text
|
|
141
|
+
.split(NL)
|
|
142
|
+
.map((line) => (line.length > 0 ? prefix + line : line))
|
|
143
|
+
.join(NL);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A titled panel with content underneath. Top line is the section name,
|
|
147
|
+
* content is indented, ends with a blank line.
|
|
148
|
+
*
|
|
149
|
+
* Foundations
|
|
150
|
+
* grid: 8px
|
|
151
|
+
* control radius: 2px
|
|
152
|
+
* container: 4px
|
|
153
|
+
*
|
|
154
|
+
* Pass `lines` already-formatted (kv, bullet, etc.) — panel just stitches.
|
|
155
|
+
*/
|
|
156
|
+
export function panel(title, lines) {
|
|
157
|
+
return section(title) + NL + indent(lines.join(NL));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A summary footer — typically used at the end of a long command to
|
|
161
|
+
* recap counts or status. Renders as a single line with separators.
|
|
162
|
+
*
|
|
163
|
+
* accepted 5 · rejected 0 · skipped 1
|
|
164
|
+
*/
|
|
165
|
+
export function summary(parts) {
|
|
166
|
+
const rendered = parts.map((p) => {
|
|
167
|
+
const valueStr = String(p.value);
|
|
168
|
+
const styled = p.tone === "ok" ? success(valueStr)
|
|
169
|
+
: p.tone === "warn" ? warning(valueStr)
|
|
170
|
+
: p.tone === "danger" ? danger(valueStr)
|
|
171
|
+
: p.tone === "muted" ? muted(valueStr)
|
|
172
|
+
: valueStr;
|
|
173
|
+
return `${muted(p.label)} ${styled}`;
|
|
174
|
+
});
|
|
175
|
+
return rendered.join(muted(" · "));
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Re-exports for ergonomics — commands often `import { ui } from "..."`
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
export const ui = {
|
|
181
|
+
header,
|
|
182
|
+
section,
|
|
183
|
+
subsection,
|
|
184
|
+
rule,
|
|
185
|
+
kv,
|
|
186
|
+
bullet,
|
|
187
|
+
dot,
|
|
188
|
+
next,
|
|
189
|
+
ok,
|
|
190
|
+
fail,
|
|
191
|
+
warn,
|
|
192
|
+
note,
|
|
193
|
+
badges,
|
|
194
|
+
indent,
|
|
195
|
+
panel,
|
|
196
|
+
summary,
|
|
197
|
+
glyph,
|
|
198
|
+
// atoms passthrough for cases where a single coloured word is enough
|
|
199
|
+
accent,
|
|
200
|
+
emphasis,
|
|
201
|
+
muted,
|
|
202
|
+
success,
|
|
203
|
+
warning,
|
|
204
|
+
danger,
|
|
205
|
+
info,
|
|
206
|
+
code: codeStyle,
|
|
207
|
+
path: pathStyle
|
|
208
|
+
};
|