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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/dist/analyzer/imports.js +88 -0
  4. package/dist/analyzer/insights.js +276 -0
  5. package/dist/commands/add.js +36 -0
  6. package/dist/commands/adopt.js +180 -0
  7. package/dist/commands/adoptReview.js +267 -0
  8. package/dist/commands/component.js +93 -0
  9. package/dist/commands/createComponent.js +207 -0
  10. package/dist/commands/decision.js +98 -0
  11. package/dist/commands/docs.js +34 -0
  12. package/dist/commands/ignite.js +212 -0
  13. package/dist/commands/init.js +66 -0
  14. package/dist/commands/insights.js +123 -0
  15. package/dist/commands/mcp.js +106 -0
  16. package/dist/commands/refine.js +36 -0
  17. package/dist/commands/selene.js +516 -0
  18. package/dist/commands/sync.js +43 -0
  19. package/dist/commands/validate.js +48 -0
  20. package/dist/commands/watch.js +150 -0
  21. package/dist/commands/wiki.js +21 -0
  22. package/dist/generators/markdownGenerator.js +112 -0
  23. package/dist/generators/reportGenerator.js +50 -0
  24. package/dist/generators/wikiGenerator.js +365 -0
  25. package/dist/index.js +213 -0
  26. package/dist/mcp/server.js +404 -0
  27. package/dist/scanner/componentScanner.js +522 -0
  28. package/dist/scanner/foundationInferrer.js +174 -0
  29. package/dist/scanner/tailwindMapper.js +58 -0
  30. package/dist/scanner/tailwindScanner.js +186 -0
  31. package/dist/selene/apiClient.js +168 -0
  32. package/dist/selene/cache.js +68 -0
  33. package/dist/selene/clipboard.js +56 -0
  34. package/dist/selene/promptBuilder.js +229 -0
  35. package/dist/selene/providers.js +67 -0
  36. package/dist/selene/responseParser.js +149 -0
  37. package/dist/ui/atoms.js +30 -0
  38. package/dist/ui/blocks.js +208 -0
  39. package/dist/ui/capabilities.js +64 -0
  40. package/dist/ui/index.js +13 -0
  41. package/dist/ui/symbols.js +41 -0
  42. package/dist/ui/theme.js +78 -0
  43. package/dist/utils/components.js +40 -0
  44. package/dist/utils/config.js +31 -0
  45. package/dist/utils/decisions.js +32 -0
  46. package/dist/utils/paths.js +4 -0
  47. package/dist/utils/proposals.js +61 -0
  48. package/dist/utils/refinements.js +81 -0
  49. package/dist/utils/registry.js +45 -0
  50. package/dist/utils/writeJson.js +6 -0
  51. package/dist/validators/cssValidator.js +204 -0
  52. package/dist/version.js +1 -0
  53. package/docs/ROADMAP.md +206 -0
  54. package/package.json +76 -0
@@ -0,0 +1,516 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import ora from "ora";
4
+ import prompts from "prompts";
5
+ import { resolveTarget } from "../utils/paths.js";
6
+ import { loadConfig } from "../utils/config.js";
7
+ import { loadDecisions, appendDecision } from "../utils/decisions.js";
8
+ import { loadRefinements, appendRefinement } from "../utils/refinements.js";
9
+ import { loadComponents, upsertComponent } from "../utils/components.js";
10
+ import { generateWiki } from "../generators/wikiGenerator.js";
11
+ import { buildDescribePrompt, buildAuditPrompt, buildSuggestPrompt } from "../selene/promptBuilder.js";
12
+ import { parseJsonResponse, isDescribeResponse, isAuditResponse, isSuggestResponse } from "../selene/responseParser.js";
13
+ import { copyToClipboard } from "../selene/clipboard.js";
14
+ import { resolveProvider, describeProviderState } from "../selene/providers.js";
15
+ import { callProvider, estimateTokens, estimateCostUsd } from "../selene/apiClient.js";
16
+ import { readCache, writeCache, clearCache } from "../selene/cache.js";
17
+ import { randomUUID } from "node:crypto";
18
+ import { ui } from "../ui/index.js";
19
+ const PENDING_DIR = ".whale/selene";
20
+ // ---------------------------------------------------------------------------
21
+ // Shared helpers
22
+ // ---------------------------------------------------------------------------
23
+ async function loadContext(target) {
24
+ const [config, decisions, refinements, components] = await Promise.all([
25
+ loadConfig(target),
26
+ loadDecisions(target),
27
+ loadRefinements(target),
28
+ loadComponents(target)
29
+ ]);
30
+ return { config, decisions, refinements, components };
31
+ }
32
+ async function emitPrompt(target, prompt, kind, options) {
33
+ if (options.stdout) {
34
+ process.stdout.write(prompt);
35
+ return;
36
+ }
37
+ if (options.out) {
38
+ const outPath = path.resolve(target, options.out);
39
+ await fs.ensureDir(path.dirname(outPath));
40
+ await fs.writeFile(outPath, prompt, "utf8");
41
+ console.log(ui.ok(`Prompt written to ${path.relative(target, outPath)}`));
42
+ console.log(ui.muted(" Paste it into your AI agent of choice."));
43
+ return;
44
+ }
45
+ // Default: copy to clipboard + save a copy in .whale/selene for `apply` round-trip.
46
+ const stashDir = path.join(target, PENDING_DIR);
47
+ await fs.ensureDir(stashDir);
48
+ const stashPath = path.join(stashDir, `${kind}-${Date.now()}.prompt.md`);
49
+ await fs.writeFile(stashPath, prompt, "utf8");
50
+ const copied = await copyToClipboard(prompt);
51
+ if (copied) {
52
+ console.log(ui.ok("Prompt copied to clipboard"));
53
+ console.log(ui.muted(` Length: ${prompt.length} chars`));
54
+ console.log(ui.muted(` Stash: ${path.relative(target, stashPath)}`));
55
+ console.log();
56
+ console.log(ui.accent("Next:"));
57
+ console.log(ui.muted(" 1. Paste the prompt into Claude / ChatGPT / Cursor."));
58
+ console.log(ui.muted(" 2. Copy the response."));
59
+ console.log(ui.muted(` 3. Run: whale selene apply ${kind} (paste, then Ctrl+D)`));
60
+ }
61
+ else {
62
+ console.log(ui.warn("Could not access the clipboard — printing prompt below."));
63
+ console.log(ui.muted(` Stash: ${path.relative(target, stashPath)}`));
64
+ console.log();
65
+ console.log(ui.rule());
66
+ console.log(prompt);
67
+ console.log(ui.rule());
68
+ }
69
+ }
70
+ /**
71
+ * The orchestration shared by describe/audit/suggest.
72
+ *
73
+ * Decides between API mode and prompt mode based on:
74
+ * 1. --prompt-only forces prompt mode.
75
+ * 2. --api-only forces API mode (errors if no key).
76
+ * 3. Otherwise: API if a key is resolvable, prompt otherwise.
77
+ * 4. config.selene.autoCall = false flips the default to prompt mode.
78
+ *
79
+ * Returns the raw response text on API success, or null on prompt mode
80
+ * (the caller stops there — the user runs `whale selene apply` later).
81
+ *
82
+ * Why a single helper instead of inline logic per command: the three
83
+ * commands all need cache lookup, cost confirmation, error fallback to
84
+ * prompt mode, etc. Centralising keeps the policy consistent.
85
+ */
86
+ async function runSelene(target, kind, prompt, options) {
87
+ const config = await loadConfig(target);
88
+ const sel = config.selene ?? {};
89
+ // Step 1: figure out the mode.
90
+ let wantApi = !options.promptOnly;
91
+ if (options.apiOnly)
92
+ wantApi = true;
93
+ if (sel.autoCall === false && !options.apiOnly)
94
+ wantApi = false;
95
+ // Step 2: try to resolve a provider if we want API.
96
+ const force = options.provider ?? undefined;
97
+ const resolved = wantApi
98
+ ? resolveProvider(config, { force, modelOverride: options.model })
99
+ : null;
100
+ if (!resolved) {
101
+ if (options.apiOnly) {
102
+ console.log(ui.fail("--api-only was set but no API key is configured."));
103
+ console.log(ui.muted(" Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment."));
104
+ process.exitCode = 1;
105
+ return { rawResponse: null };
106
+ }
107
+ await emitPrompt(target, prompt, kind, options);
108
+ return { rawResponse: null };
109
+ }
110
+ // ---- API mode --------------------------------------------------------------
111
+ console.log(ui.accent(`Selene • ${resolved.provider} • ${resolved.model}`) + ui.muted(` (key from ${resolved.source})`));
112
+ // Cache lookup, unless the user opted out.
113
+ const cacheEnabled = !options.noCache && !sel.noCache;
114
+ if (cacheEnabled) {
115
+ const cached = await readCache(target, resolved.model, prompt);
116
+ if (cached) {
117
+ console.log(ui.muted(" cache hit — no API call needed"));
118
+ // Still stash the response so `apply` from disk works if the user wants.
119
+ await stashResponse(target, kind, cached);
120
+ return { rawResponse: cached };
121
+ }
122
+ }
123
+ // Cost confirmation.
124
+ const confirm = options.confirmCost || sel.confirmCost;
125
+ if (confirm) {
126
+ const inputTokens = estimateTokens(prompt);
127
+ const expectedOutput = sel.maxTokens ?? 1500;
128
+ const cost = estimateCostUsd(resolved.model, inputTokens, expectedOutput);
129
+ const costStr = cost === null ? "unknown" : `≈ $${cost.toFixed(4)}`;
130
+ console.log(ui.muted(` estimated cost: ${costStr} (${inputTokens} input + up to ${expectedOutput} output tokens)`));
131
+ const { ok } = await prompts({
132
+ type: "confirm",
133
+ name: "ok",
134
+ message: "Proceed with the call?",
135
+ initial: true
136
+ });
137
+ if (!ok) {
138
+ console.log(ui.warning("Aborted — falling back to prompt mode."));
139
+ await emitPrompt(target, prompt, kind, options);
140
+ return { rawResponse: null };
141
+ }
142
+ }
143
+ const spin = ora({ text: "Calling provider", stream: process.stderr }).start();
144
+ const result = await callProvider(resolved, {
145
+ prompt,
146
+ maxTokens: sel.maxTokens ?? 1500,
147
+ temperature: sel.temperature ?? 0.2
148
+ });
149
+ if (!result.ok) {
150
+ spin.fail(`API call failed: ${result.error}`);
151
+ if (result.retryable) {
152
+ console.log(ui.muted(" (retryable — try again later or pass --prompt-only)"));
153
+ }
154
+ // Graceful fallback: still emit the prompt so the user isn't stuck.
155
+ console.log(ui.warning("\nFalling back to prompt mode so you can run this manually:"));
156
+ await emitPrompt(target, prompt, kind, options);
157
+ return { rawResponse: null };
158
+ }
159
+ const usage = result.inputTokens !== null && result.outputTokens !== null
160
+ ? ` ${ui.muted(`(${result.inputTokens} in / ${result.outputTokens} out)`)}`
161
+ : "";
162
+ spin.succeed(`Response received${usage}`);
163
+ if (cacheEnabled) {
164
+ await writeCache(target, resolved.model, prompt, result.text);
165
+ }
166
+ await stashResponse(target, kind, result.text);
167
+ return { rawResponse: result.text };
168
+ }
169
+ async function stashResponse(target, kind, text) {
170
+ const stashDir = path.join(target, PENDING_DIR);
171
+ await fs.ensureDir(stashDir);
172
+ const stashPath = path.join(stashDir, `${kind}-${Date.now()}.response.md`);
173
+ await fs.writeFile(stashPath, text, "utf8");
174
+ }
175
+ async function readResponse(target, options) {
176
+ if (options.from) {
177
+ const p = path.resolve(target, options.from);
178
+ return await fs.readFile(p, "utf8");
179
+ }
180
+ // Read from stdin until EOF.
181
+ return await new Promise((resolve, reject) => {
182
+ if (process.stdin.isTTY) {
183
+ console.log(ui.muted("Paste the LLM response, then press Ctrl+D (or Ctrl+Z, Enter on Windows):"));
184
+ }
185
+ let buf = "";
186
+ process.stdin.setEncoding("utf8");
187
+ process.stdin.on("data", (chunk) => {
188
+ buf += chunk;
189
+ });
190
+ process.stdin.on("end", () => resolve(buf));
191
+ process.stdin.on("error", (err) => reject(err));
192
+ });
193
+ }
194
+ export async function seleneDescribeCommand(componentName, options = {}) {
195
+ const target = resolveTarget();
196
+ const ctx = await loadContext(target);
197
+ // Find the component file. Priority: --file flag, then catalog entry.
198
+ let filePath = options.file;
199
+ let existingEntry = ctx.components.find((c) => c.name === componentName);
200
+ if (!filePath && existingEntry?.files && existingEntry.files.length > 0) {
201
+ filePath = existingEntry.files[0];
202
+ }
203
+ if (!filePath) {
204
+ console.log(ui.fail(`Component "${componentName}" not found in the catalog and no --file given.`));
205
+ console.log(ui.muted(" Pass --file <path> or register the component first."));
206
+ process.exitCode = 1;
207
+ return;
208
+ }
209
+ const absFile = path.resolve(target, filePath);
210
+ if (!(await fs.pathExists(absFile))) {
211
+ console.log(ui.fail(`File not found: ${filePath}`));
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ const fileContents = await fs.readFile(absFile, "utf8");
216
+ const prompt = buildDescribePrompt(ctx, {
217
+ componentName,
218
+ filePath,
219
+ fileContents,
220
+ existingEntry
221
+ });
222
+ const { rawResponse } = await runSelene(target, "describe", prompt, options);
223
+ if (!rawResponse)
224
+ return; // Prompt-mode path — user will run `apply` later.
225
+ // API-mode path: auto-apply (with confirmation unless --yes is passed).
226
+ if (options.autoApply === false) {
227
+ console.log(ui.muted("\nResponse received but --no-auto-apply was set. Run `whale selene apply describe`."));
228
+ return;
229
+ }
230
+ await applyDescribe(target, rawResponse, {});
231
+ }
232
+ // ---------------------------------------------------------------------------
233
+ // audit
234
+ // ---------------------------------------------------------------------------
235
+ export async function seleneAuditCommand(filePath, options = {}) {
236
+ const target = resolveTarget();
237
+ const ctx = await loadContext(target);
238
+ const absFile = path.resolve(target, filePath);
239
+ if (!(await fs.pathExists(absFile))) {
240
+ console.log(ui.fail(`File not found: ${filePath}`));
241
+ process.exitCode = 1;
242
+ return;
243
+ }
244
+ const fileContents = await fs.readFile(absFile, "utf8");
245
+ const prompt = buildAuditPrompt(ctx, { filePath, fileContents });
246
+ const { rawResponse } = await runSelene(target, "audit", prompt, options);
247
+ if (!rawResponse)
248
+ return;
249
+ if (options.autoApply === false) {
250
+ console.log(ui.muted("\nResponse received but --no-auto-apply was set. Run `whale selene apply audit`."));
251
+ return;
252
+ }
253
+ await applyAudit(target, rawResponse, {});
254
+ }
255
+ export async function seleneSuggestCommand(options = {}) {
256
+ const target = resolveTarget();
257
+ const ctx = await loadContext(target);
258
+ const validFocus = ["decisions", "refinements", "components", "all"];
259
+ const focus = options.focus && validFocus.includes(options.focus)
260
+ ? options.focus
261
+ : undefined;
262
+ const prompt = buildSuggestPrompt(ctx, { focus });
263
+ const { rawResponse } = await runSelene(target, "suggest", prompt, options);
264
+ if (!rawResponse)
265
+ return;
266
+ if (options.autoApply === false) {
267
+ console.log(ui.muted("\nResponse received but --no-auto-apply was set. Run `whale selene apply suggest`."));
268
+ return;
269
+ }
270
+ await applySuggest(target, rawResponse, { yes: options.yes });
271
+ }
272
+ export async function seleneApplyCommand(kind, options = {}) {
273
+ const target = resolveTarget();
274
+ const raw = await readResponse(target, options);
275
+ if (!raw.trim()) {
276
+ console.log(ui.fail("Empty input. Nothing to apply."));
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+ if (kind === "describe")
281
+ return applyDescribe(target, raw, options);
282
+ if (kind === "audit")
283
+ return applyAudit(target, raw, options);
284
+ if (kind === "suggest")
285
+ return applySuggest(target, raw, options);
286
+ console.log(ui.fail(`Unknown apply kind: ${kind}`));
287
+ process.exitCode = 1;
288
+ }
289
+ async function applyDescribe(target, raw, options) {
290
+ const parsed = parseJsonResponse(raw, isDescribeResponse);
291
+ if (!parsed.ok) {
292
+ console.log(ui.fail(`${parsed.reason}`));
293
+ console.log(ui.muted(" Expected a fenced JSON block with `name` and `description`."));
294
+ process.exitCode = 1;
295
+ return;
296
+ }
297
+ const v = parsed.value;
298
+ console.log(ui.emphasis(`\nProposed catalog update for ${v.name}\n`));
299
+ console.log(` description: ${ui.accent(v.description)}`);
300
+ if (v.category)
301
+ console.log(` category: ${v.category}`);
302
+ if (v.variants?.length)
303
+ console.log(` variants: ${v.variants.join(", ")}`);
304
+ if (v.states?.length)
305
+ console.log(` states: ${v.states.join(", ")}`);
306
+ if (v.tokens?.length)
307
+ console.log(` tokens: ${v.tokens.slice(0, 8).join(", ")}`);
308
+ if (!options.yes) {
309
+ const { ok } = await prompts({
310
+ type: "confirm",
311
+ name: "ok",
312
+ message: "Apply to intelligence/components.json?",
313
+ initial: true
314
+ });
315
+ if (!ok) {
316
+ console.log(ui.warning("Aborted."));
317
+ return;
318
+ }
319
+ }
320
+ await upsertComponent(target, {
321
+ name: v.name,
322
+ description: v.description,
323
+ category: v.category,
324
+ variants: v.variants,
325
+ states: v.states,
326
+ tokens: v.tokens
327
+ });
328
+ await generateWiki(target);
329
+ console.log(ui.ok("Catalog updated and AI context regenerated."));
330
+ }
331
+ async function applyAudit(target, raw, options) {
332
+ const parsed = parseJsonResponse(raw, isAuditResponse);
333
+ if (!parsed.ok) {
334
+ console.log(ui.fail(`${parsed.reason}`));
335
+ process.exitCode = 1;
336
+ return;
337
+ }
338
+ const v = parsed.value;
339
+ if (v.issues.length === 0) {
340
+ console.log(ui.ok(`${v.file} — no issues reported.`));
341
+ return;
342
+ }
343
+ // Audit results are surfaced, not auto-applied. We write a markdown
344
+ // report into docs/selene/ that the user (or a CI step) can act on.
345
+ const reportDir = path.join(target, "docs", "selene");
346
+ await fs.ensureDir(reportDir);
347
+ const slug = v.file.replace(/[\\/]/g, "_").replace(/\.[^.]+$/, "");
348
+ const reportPath = path.join(reportDir, `audit-${slug}-${Date.now()}.md`);
349
+ const lines = [`# Selene audit — ${v.file}`, "", `Generated: ${new Date().toISOString()}`, ""];
350
+ for (const i of v.issues) {
351
+ lines.push(`## ${i.severity.toUpperCase()} — ${i.rule}${i.line ? ` (line ${i.line})` : ""}`);
352
+ lines.push("");
353
+ lines.push(i.message);
354
+ if (i.suggestion) {
355
+ lines.push("");
356
+ lines.push(`**Suggestion:** ${i.suggestion}`);
357
+ }
358
+ lines.push("");
359
+ }
360
+ await fs.writeFile(reportPath, lines.join("\n"), "utf8");
361
+ // Console summary.
362
+ console.log(ui.emphasis(`\nSelene audit — ${v.file}`));
363
+ console.log(ui.muted(`${v.issues.length} issue(s) reported. Full report:`));
364
+ console.log(ui.accent(` ${path.relative(target, reportPath)}`));
365
+ console.log();
366
+ for (const i of v.issues) {
367
+ const sev = i.severity === "critical" ? ui.emphasis(ui.danger("CRITICAL"))
368
+ : i.severity === "warning" ? ui.emphasis(ui.warning("WARNING"))
369
+ : ui.emphasis(ui.accent("INFO"));
370
+ console.log(` ${sev} ${ui.muted(`[${i.rule}${i.line ? `:${i.line}` : ""}]`)} ${i.message}`);
371
+ if (i.suggestion)
372
+ console.log(ui.muted(` → ${i.suggestion}`));
373
+ }
374
+ }
375
+ async function applySuggest(target, raw, options) {
376
+ const parsed = parseJsonResponse(raw, isSuggestResponse);
377
+ if (!parsed.ok) {
378
+ console.log(ui.fail(`${parsed.reason}`));
379
+ process.exitCode = 1;
380
+ return;
381
+ }
382
+ const v = parsed.value;
383
+ if (v.suggestions.length === 0) {
384
+ console.log(ui.muted("No suggestions in the response."));
385
+ return;
386
+ }
387
+ console.log(ui.emphasis(`\n${v.suggestions.length} suggestion(s) from Selene\n`));
388
+ let applied = 0;
389
+ let skipped = 0;
390
+ for (const s of v.suggestions) {
391
+ console.log(ui.emphasis(s.title));
392
+ console.log(ui.muted(` kind: ${s.kind} (${s.category})`));
393
+ console.log(ui.muted(` ${s.rationale}`));
394
+ console.log();
395
+ console.log(" Proposed:");
396
+ console.log(" " + ui.accent(s.proposed_text.split("\n").join("\n ")));
397
+ console.log();
398
+ let action = "accept";
399
+ if (!options.yes) {
400
+ const { choice } = await prompts({
401
+ type: "select",
402
+ name: "choice",
403
+ message: "Action:",
404
+ choices: [
405
+ { title: "Accept (record it)", value: "accept" },
406
+ { title: "Skip", value: "skip" },
407
+ { title: "Reject permanently", value: "reject" }
408
+ ]
409
+ });
410
+ action = choice ?? "skip";
411
+ }
412
+ if (action === "accept") {
413
+ if (s.kind === "decision" || s.kind === "foundation") {
414
+ await appendDecision(target, {
415
+ title: s.title,
416
+ category: s.category,
417
+ context: s.rationale,
418
+ decision: s.proposed_text
419
+ });
420
+ console.log(ui.success(" ✓ Recorded as decision."));
421
+ }
422
+ else if (s.kind === "refinement") {
423
+ await appendRefinement(target, {
424
+ id: randomUUID(),
425
+ timestamp: new Date().toISOString(),
426
+ note: s.proposed_text
427
+ });
428
+ console.log(ui.success(" ✓ Recorded as refinement."));
429
+ }
430
+ else {
431
+ // cleanup — surfaced but not auto-applied.
432
+ console.log(ui.muted(" (cleanup suggestion — surfaced, not auto-applied)"));
433
+ }
434
+ applied += 1;
435
+ }
436
+ else {
437
+ skipped += 1;
438
+ }
439
+ console.log();
440
+ }
441
+ if (applied > 0) {
442
+ await generateWiki(target);
443
+ console.log(ui.muted("✓ AI context regenerated."));
444
+ }
445
+ console.log();
446
+ console.log(`Applied ${applied}, skipped ${skipped}.`);
447
+ }
448
+ // ---------------------------------------------------------------------------
449
+ // Utility commands: status and cache
450
+ // ---------------------------------------------------------------------------
451
+ export async function seleneStatusCommand() {
452
+ const target = resolveTarget();
453
+ const config = await loadConfig(target);
454
+ const sel = config.selene ?? {};
455
+ console.log();
456
+ console.log(ui.header("Whale Igniter", "selene status"));
457
+ console.log();
458
+ console.log(ui.section("Providers"));
459
+ for (const p of describeProviderState()) {
460
+ const mark = p.available ? ui.glyph.check : ui.muted("·");
461
+ const envNote = p.available ? ui.muted(`(${p.envName} set)`) : ui.muted(`(${p.envName} not set)`);
462
+ console.log(ui.indent(`${mark} ${p.provider} ${envNote}`));
463
+ }
464
+ console.log();
465
+ console.log(ui.section("Configuration"));
466
+ if (Object.keys(sel).length === 0) {
467
+ console.log(ui.indent(ui.muted("(empty — using defaults)")));
468
+ }
469
+ else {
470
+ const cfgLines = [];
471
+ if (sel.provider)
472
+ cfgLines.push(ui.kv("provider", sel.provider, { keyWidth: 12 }));
473
+ if (sel.model)
474
+ cfgLines.push(ui.kv("model", ui.code(sel.model), { keyWidth: 12 }));
475
+ if (sel.temperature !== undefined)
476
+ cfgLines.push(ui.kv("temperature", String(sel.temperature), { keyWidth: 12 }));
477
+ if (sel.maxTokens !== undefined)
478
+ cfgLines.push(ui.kv("maxTokens", String(sel.maxTokens), { keyWidth: 12 }));
479
+ if (sel.autoCall !== undefined)
480
+ cfgLines.push(ui.kv("autoCall", String(sel.autoCall), { keyWidth: 12 }));
481
+ if (sel.confirmCost !== undefined)
482
+ cfgLines.push(ui.kv("confirmCost", String(sel.confirmCost), { keyWidth: 12 }));
483
+ if (sel.noCache !== undefined)
484
+ cfgLines.push(ui.kv("noCache", String(sel.noCache), { keyWidth: 12 }));
485
+ console.log(ui.indent(cfgLines.join("\n")));
486
+ }
487
+ // What would actually happen on a Selene call right now?
488
+ const resolved = resolveProvider(config);
489
+ console.log();
490
+ console.log(ui.section("Active mode"));
491
+ if (resolved) {
492
+ console.log(ui.indent(`${ui.glyph.check} ${ui.success("API")} ${ui.muted(`(${resolved.provider} • ${resolved.model})`)}`));
493
+ }
494
+ else {
495
+ console.log(ui.indent(`${ui.glyph.info} ${ui.warning("prompt-only")} ${ui.muted("(no API key found)")}`));
496
+ console.log(ui.indent(ui.muted("Set ANTHROPIC_API_KEY or OPENAI_API_KEY to enable API mode.")));
497
+ }
498
+ console.log();
499
+ console.log(ui.section("Cache"));
500
+ const cacheDir = path.join(target, ".whale/selene/cache");
501
+ if (await fs.pathExists(cacheDir)) {
502
+ const entries = (await fs.readdir(cacheDir)).filter((f) => f.endsWith(".json")).length;
503
+ console.log(ui.indent(`${entries} ${ui.muted("entry/entries at .whale/selene/cache")}`));
504
+ }
505
+ else {
506
+ console.log(ui.indent(ui.muted("empty")));
507
+ }
508
+ console.log();
509
+ }
510
+ export async function seleneCacheClearCommand() {
511
+ const target = resolveTarget();
512
+ const removed = await clearCache(target);
513
+ console.log();
514
+ console.log(ui.ok(`Cleared ${removed} cache entry/entries.`));
515
+ console.log();
516
+ }
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ import ora from "ora";
3
+ import { resolveTarget } from "../utils/paths.js";
4
+ import { generateWiki } from "../generators/wikiGenerator.js";
5
+ import { loadConfig } from "../utils/config.js";
6
+ import { loadDecisions } from "../utils/decisions.js";
7
+ import { loadComponents } from "../utils/components.js";
8
+ import { loadRefinements } from "../utils/refinements.js";
9
+ import { ui } from "../ui/index.js";
10
+ /**
11
+ * `whale sync` regenerates everything an AI agent reads, from the
12
+ * intelligence JSON files (which are the source of truth).
13
+ *
14
+ * Run this:
15
+ * - after manually editing intelligence/*.json
16
+ * - before handing the project to an AI agent
17
+ * - in CI to ensure CLAUDE.md stays in sync
18
+ */
19
+ export async function syncCommand(targetArg) {
20
+ const target = resolveTarget(targetArg);
21
+ const spinner = ora("Reading intelligence stores").start();
22
+ const config = await loadConfig(target);
23
+ const decisions = await loadDecisions(target);
24
+ const components = await loadComponents(target);
25
+ const refinements = await loadRefinements(target);
26
+ spinner.succeed(`Loaded: ${decisions.length} decision(s), ${components.length} component(s), ${refinements.length} refinement(s)`);
27
+ const spin2 = ora("Regenerating AI context").start();
28
+ const { rootFiles, wikiFiles } = await generateWiki(target);
29
+ spin2.succeed("AI context regenerated");
30
+ console.log();
31
+ console.log(ui.section("Root entry points"));
32
+ for (const file of rootFiles) {
33
+ console.log(` ${ui.ok(ui.path(path.relative(target, file)))}`);
34
+ }
35
+ console.log();
36
+ console.log(ui.section("Wiki"));
37
+ for (const file of wikiFiles) {
38
+ console.log(` ${ui.ok(ui.path(path.relative(target, file)))}`);
39
+ }
40
+ console.log();
41
+ console.log(ui.accent("AI agents will now see the latest project state."));
42
+ console.log(ui.muted(`Targets configured: ${(config.aiTargets ?? []).join(", ") || "(none)"}`));
43
+ }
@@ -0,0 +1,48 @@
1
+ import ora from "ora";
2
+ import { resolveTarget } from "../utils/paths.js";
3
+ import { validateCss, hasErrors } from "../validators/cssValidator.js";
4
+ import { ui } from "../ui/index.js";
5
+ export async function validateCommand(targetArg) {
6
+ const target = resolveTarget(targetArg);
7
+ const spinner = ora("Scanning workspace").start();
8
+ let issues;
9
+ try {
10
+ issues = await validateCss(target);
11
+ }
12
+ catch (err) {
13
+ spinner.fail(`Validation failed: ${err.message}`);
14
+ process.exitCode = 2;
15
+ return;
16
+ }
17
+ spinner.succeed("Workspace scanned");
18
+ const errorCount = issues.filter((i) => i.severity === "error").length;
19
+ const warningCount = issues.filter((i) => i.severity === "warning").length;
20
+ if (issues.length === 0) {
21
+ console.log();
22
+ console.log(ui.ok("No issues found."));
23
+ process.exitCode = 0;
24
+ return;
25
+ }
26
+ // Header with the counts as badges.
27
+ console.log();
28
+ console.log(ui.summary([
29
+ { label: "issues", value: issues.length },
30
+ { label: "errors", value: errorCount, tone: errorCount > 0 ? "danger" : "muted" },
31
+ { label: "warnings", value: warningCount, tone: warningCount > 0 ? "warn" : "muted" }
32
+ ]));
33
+ console.log();
34
+ for (const issue of issues) {
35
+ const head = issue.severity === "error"
36
+ ? `${ui.glyph.cross} ${ui.danger(issue.type)}`
37
+ : `${ui.glyph.warn} ${ui.warning(issue.type)}`;
38
+ console.log(`${head} ${ui.muted(`— ${issue.file}:${issue.line}:${issue.column}`)}`);
39
+ console.log(` ${issue.message}`);
40
+ console.log(` ${ui.next(issue.suggestion)}`);
41
+ if (issue.selector) {
42
+ console.log(` ${ui.muted("selector: " + issue.selector)}`);
43
+ }
44
+ console.log();
45
+ }
46
+ // Exit code: errors fail the run, warnings don't.
47
+ process.exitCode = hasErrors(issues) ? 1 : 0;
48
+ }