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,404 @@
1
+ /**
2
+ * Whale Igniter MCP server.
3
+ *
4
+ * Exposes Whale's core capabilities as MCP tools so AI clients (Claude
5
+ * Code, Cursor, Zed, etc.) can call them directly without going through
6
+ * the CLI. This is the v1.0 endgame: instead of asking the user to run
7
+ * `whale component add Button`, the agent does it autonomously when it
8
+ * creates the component.
9
+ *
10
+ * Wired over stdio, the universal MCP transport. Start it from a client
11
+ * config (e.g. `claude_desktop_config.json`) pointing at:
12
+ *
13
+ * { "command": "whale", "args": ["mcp", "serve"] }
14
+ *
15
+ * Design principles:
16
+ *
17
+ * 1. Read tools are non-destructive and always available. The agent
18
+ * can introspect freely.
19
+ * 2. Write tools touch real files. They return human-readable summaries
20
+ * so the agent can confirm with the user before/after.
21
+ * 3. Tool descriptions are written for AI consumption: they answer
22
+ * "when should I call this?" not "what does this do?".
23
+ * 4. Errors are returned as `isError: true` content, not thrown — the
24
+ * client expects the protocol shape, not exceptions.
25
+ */
26
+ import path from "node:path";
27
+ import fs from "fs-extra";
28
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
29
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
+ import { z } from "zod";
31
+ import { loadConfig } from "../utils/config.js";
32
+ import { loadComponents, upsertComponent } from "../utils/components.js";
33
+ import { loadDecisions, appendDecision } from "../utils/decisions.js";
34
+ import { loadRefinements, appendRefinement, inferScope } from "../utils/refinements.js";
35
+ import { generateWiki } from "../generators/wikiGenerator.js";
36
+ import { analyze } from "../analyzer/insights.js";
37
+ import { collectReferencedFiles, normalizePath } from "../analyzer/imports.js";
38
+ import { scanComponents } from "../scanner/componentScanner.js";
39
+ import { aggregateTailwind } from "../scanner/tailwindScanner.js";
40
+ import { validateCss } from "../validators/cssValidator.js";
41
+ import { randomUUID } from "node:crypto";
42
+ import { PACKAGE_VERSION } from "../version.js";
43
+ function ok(text) {
44
+ return { content: [{ type: "text", text }] };
45
+ }
46
+ function err(text) {
47
+ return { content: [{ type: "text", text }], isError: true };
48
+ }
49
+ function jsonOk(value) {
50
+ return ok(JSON.stringify(value, null, 2));
51
+ }
52
+ /**
53
+ * Resolve the target workspace.
54
+ *
55
+ * Priority: explicit `target` argument > WHALE_PROJECT env var > cwd.
56
+ * The env var matters because MCP servers are usually launched by the
57
+ * client (e.g. Claude Code) and cwd may not be the user's project.
58
+ */
59
+ function resolveWorkspace(target) {
60
+ if (target)
61
+ return path.resolve(target);
62
+ if (process.env.WHALE_PROJECT)
63
+ return path.resolve(process.env.WHALE_PROJECT);
64
+ return process.cwd();
65
+ }
66
+ async function ensureWhaleProject(target) {
67
+ const config = path.join(target, "whale.config.json");
68
+ if (!(await fs.pathExists(config))) {
69
+ return {
70
+ ok: false,
71
+ reason: `No whale.config.json found at ${target}. ` +
72
+ `Run \`whale ignite\` to bootstrap a workspace, or \`whale adopt\` to scan an existing project.`
73
+ };
74
+ }
75
+ return { ok: true };
76
+ }
77
+ export function buildMcpServer() {
78
+ const server = new McpServer({
79
+ name: "whale-igniter",
80
+ version: PACKAGE_VERSION
81
+ });
82
+ // -------------------------------------------------------------------------
83
+ // Read tools — always safe, used by the agent to understand the project.
84
+ // -------------------------------------------------------------------------
85
+ server.registerTool("whale_project_overview", {
86
+ title: "Get project overview",
87
+ description: "Returns the project's design-system foundations (grid, radii), stack, " +
88
+ "active packs, AI targets, and counts of decisions, refinements, and " +
89
+ "components. Call this FIRST when you don't yet have context about a project " +
90
+ "managed by Whale.",
91
+ inputSchema: {
92
+ target: z.string().optional().describe("Workspace path. Defaults to WHALE_PROJECT or cwd.")
93
+ }
94
+ }, async ({ target }) => {
95
+ const ws = resolveWorkspace(target);
96
+ const check = await ensureWhaleProject(ws);
97
+ if (!check.ok)
98
+ return err(check.reason);
99
+ const [config, decisions, refinements, components] = await Promise.all([
100
+ loadConfig(ws),
101
+ loadDecisions(ws),
102
+ loadRefinements(ws),
103
+ loadComponents(ws)
104
+ ]);
105
+ return jsonOk({
106
+ project: {
107
+ name: config.projectName,
108
+ type: config.projectType,
109
+ stack: config.stack,
110
+ aiTargets: config.aiTargets
111
+ },
112
+ foundations: config.foundations,
113
+ packs: config.packs,
114
+ counts: {
115
+ decisions: decisions.length,
116
+ activeDecisions: decisions.filter((d) => d.status === "active").length,
117
+ refinements: refinements.length,
118
+ components: components.length
119
+ },
120
+ ignited: config.ignited
121
+ });
122
+ });
123
+ server.registerTool("whale_list_components", {
124
+ title: "List components in the catalog",
125
+ description: "Returns the registered components: names, categories, variants, files. " +
126
+ "Use this BEFORE creating a new component, to check whether something " +
127
+ "similar already exists in the project.",
128
+ inputSchema: {
129
+ target: z.string().optional(),
130
+ category: z.string().optional().describe("Filter by category (form, navigation, surface, etc.)")
131
+ }
132
+ }, async ({ target, category }) => {
133
+ const ws = resolveWorkspace(target);
134
+ const check = await ensureWhaleProject(ws);
135
+ if (!check.ok)
136
+ return err(check.reason);
137
+ let components = await loadComponents(ws);
138
+ if (category)
139
+ components = components.filter((c) => c.category === category);
140
+ return jsonOk(components.map((c) => ({
141
+ name: c.name,
142
+ category: c.category,
143
+ description: c.description,
144
+ variants: c.variants,
145
+ states: c.states,
146
+ files: c.files
147
+ })));
148
+ });
149
+ server.registerTool("whale_list_decisions", {
150
+ title: "List recorded design / architecture decisions",
151
+ description: "Returns the decisions log: title, category, decision text, status. " +
152
+ "Call this when you're about to make a non-obvious choice that might " +
153
+ "already have been decided, or when answering questions about why the " +
154
+ "project is structured a certain way.",
155
+ inputSchema: {
156
+ target: z.string().optional(),
157
+ category: z.string().optional().describe("Filter by category: architecture | design-system | product | tooling | convention"),
158
+ activeOnly: z.boolean().optional().describe("Only show decisions with status='active' (default true).")
159
+ }
160
+ }, async ({ target, category, activeOnly }) => {
161
+ const ws = resolveWorkspace(target);
162
+ const check = await ensureWhaleProject(ws);
163
+ if (!check.ok)
164
+ return err(check.reason);
165
+ let decisions = await loadDecisions(ws);
166
+ if (activeOnly !== false)
167
+ decisions = decisions.filter((d) => d.status === "active");
168
+ if (category)
169
+ decisions = decisions.filter((d) => d.category === category);
170
+ decisions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
171
+ return jsonOk(decisions.map((d) => ({
172
+ title: d.title,
173
+ category: d.category,
174
+ status: d.status,
175
+ context: d.context,
176
+ decision: d.decision,
177
+ consequences: d.consequences,
178
+ date: d.timestamp.slice(0, 10)
179
+ })));
180
+ });
181
+ server.registerTool("whale_list_refinements", {
182
+ title: "List approved exceptions (refinements)",
183
+ description: "Refinements are exceptions to the design system that the team has explicitly " +
184
+ "approved. The validator suppresses matching issues. Call this BEFORE flagging " +
185
+ "something that looks like a violation — it may be a sanctioned exception.",
186
+ inputSchema: { target: z.string().optional() }
187
+ }, async ({ target }) => {
188
+ const ws = resolveWorkspace(target);
189
+ const check = await ensureWhaleProject(ws);
190
+ if (!check.ok)
191
+ return err(check.reason);
192
+ const refinements = await loadRefinements(ws);
193
+ return jsonOk(refinements.map((r) => ({
194
+ note: r.note,
195
+ scope: r.scope,
196
+ date: r.timestamp.slice(0, 10)
197
+ })));
198
+ });
199
+ server.registerTool("whale_validate", {
200
+ title: "Run validators on the project",
201
+ description: "Runs the CSS/foundation validators and returns issues found. Each issue " +
202
+ "has severity (error|warning), rule, file, line, and a message. Use this " +
203
+ "after making changes to verify they don't break the system, or to get a " +
204
+ "snapshot of current debt.",
205
+ inputSchema: { target: z.string().optional() }
206
+ }, async ({ target }) => {
207
+ const ws = resolveWorkspace(target);
208
+ const check = await ensureWhaleProject(ws);
209
+ if (!check.ok)
210
+ return err(check.reason);
211
+ const issues = await validateCss(ws);
212
+ return jsonOk({
213
+ summary: {
214
+ errors: issues.filter((i) => i.severity === "error").length,
215
+ warnings: issues.filter((i) => i.severity === "warning").length,
216
+ total: issues.length
217
+ },
218
+ issues
219
+ });
220
+ });
221
+ server.registerTool("whale_insights", {
222
+ title: "Run local analyzers and surface accountable recommendations",
223
+ description: "Returns insights about the project state: refinement clusters, decisions " +
224
+ "in tension, orphan components, token drift, grid drift, catalog coverage " +
225
+ "gaps. All deterministic, no AI. Call this when the user asks 'how is the " +
226
+ "project doing?' or 'what should we clean up?'.",
227
+ inputSchema: {
228
+ target: z.string().optional(),
229
+ skipScan: z.boolean().optional().describe("Skip source scan (faster but loses orphan/drift insights).")
230
+ }
231
+ }, async ({ target, skipScan }) => {
232
+ const ws = resolveWorkspace(target);
233
+ const check = await ensureWhaleProject(ws);
234
+ if (!check.ok)
235
+ return err(check.reason);
236
+ const config = await loadConfig(ws);
237
+ const refinements = await loadRefinements(ws);
238
+ const decisions = await loadDecisions(ws);
239
+ const components = await loadComponents(ws);
240
+ let referencedFiles;
241
+ let observations;
242
+ if (!skipScan) {
243
+ try {
244
+ const [refs, scanned] = await Promise.all([
245
+ collectReferencedFiles(ws),
246
+ scanComponents(ws)
247
+ ]);
248
+ referencedFiles = refs;
249
+ const allClassStrings = scanned.flatMap((c) => c.classNames);
250
+ observations = allClassStrings.length > 0 ? aggregateTailwind(allClassStrings) : undefined;
251
+ }
252
+ catch {
253
+ // Continue without scan — better partial output than failure.
254
+ }
255
+ }
256
+ const normalisedComponents = components.map((c) => ({
257
+ ...c,
258
+ files: c.files?.map(normalizePath)
259
+ }));
260
+ const insights = analyze({
261
+ config,
262
+ refinements,
263
+ decisions,
264
+ components: normalisedComponents,
265
+ observations,
266
+ referencedFiles
267
+ });
268
+ return jsonOk(insights);
269
+ });
270
+ // -------------------------------------------------------------------------
271
+ // Write tools — touch real files. Used by agents to record context as they
272
+ // work, so the project's memory grows automatically.
273
+ // -------------------------------------------------------------------------
274
+ server.registerTool("whale_register_component", {
275
+ title: "Add a component to the catalog",
276
+ description: "Register a component you've just created (or that you discovered during " +
277
+ "work) in intelligence/components.json. The user's CLAUDE.md updates " +
278
+ "automatically. Call this AFTER creating a component file, so future " +
279
+ "sessions know it exists.",
280
+ inputSchema: {
281
+ target: z.string().optional(),
282
+ name: z.string().describe("PascalCase component name."),
283
+ description: z.string().optional().describe("One-sentence description, ≤ 140 chars."),
284
+ category: z.string().optional().describe("form | navigation | feedback | surface | layout | data | overlay"),
285
+ variants: z.array(z.string()).optional().describe("Visual variants (e.g. primary, secondary)."),
286
+ states: z.array(z.string()).optional().describe("Interactive states (hover, focus, disabled)."),
287
+ files: z.array(z.string()).optional().describe("Paths relative to project root."),
288
+ tokens: z.array(z.string()).optional().describe("Design tokens the component depends on.")
289
+ }
290
+ }, async ({ target, name, description, category, variants, states, files, tokens }) => {
291
+ const ws = resolveWorkspace(target);
292
+ const check = await ensureWhaleProject(ws);
293
+ if (!check.ok)
294
+ return err(check.reason);
295
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) {
296
+ return err(`Component name must be PascalCase. Got: ${name}`);
297
+ }
298
+ const entry = await upsertComponent(ws, {
299
+ name,
300
+ description,
301
+ category,
302
+ variants,
303
+ states,
304
+ files,
305
+ tokens
306
+ });
307
+ await generateWiki(ws);
308
+ return ok(`Registered ${entry.name}. CLAUDE.md and the wiki have been regenerated.\n` +
309
+ `Details: category=${entry.category ?? "(none)"}, variants=${entry.variants?.join(",") ?? "(none)"}.`);
310
+ });
311
+ server.registerTool("whale_record_decision", {
312
+ title: "Record an architectural / product / design decision",
313
+ description: "Whenever you (or the user) make a non-obvious choice during work, " +
314
+ "record it here. Future agents and humans inherit the reasoning. " +
315
+ "Don't use this for trivial decisions like variable names; do use it for " +
316
+ "anything that took real thought: 'we chose Tailwind over CSS modules because…', " +
317
+ "'all controls use forwardRef so consumers can attach refs', etc.",
318
+ inputSchema: {
319
+ target: z.string().optional(),
320
+ title: z.string().describe("One-line summary of the decision."),
321
+ category: z.enum(["architecture", "design-system", "product", "tooling", "convention"]),
322
+ context: z.string().optional().describe("Why this came up. 1-3 sentences."),
323
+ decision: z.string().describe("What was decided. Concrete."),
324
+ consequences: z.string().optional().describe("Tradeoffs or implications.")
325
+ }
326
+ }, async ({ target, title, category, context, decision, consequences }) => {
327
+ const ws = resolveWorkspace(target);
328
+ const check = await ensureWhaleProject(ws);
329
+ if (!check.ok)
330
+ return err(check.reason);
331
+ const created = await appendDecision(ws, {
332
+ title,
333
+ category: category,
334
+ context: context?.trim() || undefined,
335
+ decision,
336
+ consequences: consequences?.trim() || undefined
337
+ });
338
+ await generateWiki(ws);
339
+ return ok(`Recorded decision "${created.title}" (id ${created.id.slice(0, 8)}). Wiki updated.`);
340
+ });
341
+ server.registerTool("whale_record_refinement", {
342
+ title: "Record an approved exception to the design system",
343
+ description: "Use when an apparent violation is intentional — e.g. 'Dashboard cards use " +
344
+ "radius 0 by design'. The validator infers scope from the note and " +
345
+ "suppresses matching issues. Mention an issue type (radius, spacing, hex, " +
346
+ "focus) in the note for the scope to attach.",
347
+ inputSchema: {
348
+ target: z.string().optional(),
349
+ note: z.string().describe("Plain-language explanation of the exception.")
350
+ }
351
+ }, async ({ target, note }) => {
352
+ const ws = resolveWorkspace(target);
353
+ const check = await ensureWhaleProject(ws);
354
+ if (!check.ok)
355
+ return err(check.reason);
356
+ const scope = inferScope(note);
357
+ await appendRefinement(ws, {
358
+ id: randomUUID(),
359
+ timestamp: new Date().toISOString(),
360
+ note,
361
+ scope
362
+ });
363
+ await generateWiki(ws);
364
+ const scopeDesc = scope
365
+ ? `Scope inferred: ${Object.entries(scope).map(([k, v]) => `${k}=${v}`).join(", ")}.`
366
+ : "No scope inferred — note recorded but validator won't act on it.";
367
+ return ok(`Refinement recorded. ${scopeDesc}`);
368
+ });
369
+ server.registerTool("whale_sync", {
370
+ title: "Regenerate CLAUDE.md and the LLM wiki",
371
+ description: "Regenerates the AI-readable context files from intelligence/*.json. Call " +
372
+ "this if you've manually edited the JSON stores and want the wiki to catch " +
373
+ "up. Whale auto-syncs on every recorded change, so manual calls are rarely needed.",
374
+ inputSchema: { target: z.string().optional() }
375
+ }, async ({ target }) => {
376
+ const ws = resolveWorkspace(target);
377
+ const check = await ensureWhaleProject(ws);
378
+ if (!check.ok)
379
+ return err(check.reason);
380
+ const { rootFiles, wikiFiles } = await generateWiki(ws);
381
+ return ok(`Regenerated ${rootFiles.length} root file(s) and ${wikiFiles.length} wiki file(s).\n` +
382
+ `Root: ${rootFiles.map((f) => path.relative(ws, f)).join(", ")}`);
383
+ });
384
+ return server;
385
+ }
386
+ /**
387
+ * Entry point used by `whale mcp serve`. Wires the server to stdio and
388
+ * blocks until the client disconnects. Errors during startup write to
389
+ * stderr so they appear in client logs; stdout is reserved for protocol.
390
+ */
391
+ export async function startMcpServer() {
392
+ const server = buildMcpServer();
393
+ const transport = new StdioServerTransport();
394
+ try {
395
+ await server.connect(transport);
396
+ // The server runs until the client closes the transport. Don't
397
+ // print anything to stdout — it would corrupt the MCP framing.
398
+ }
399
+ catch (e) {
400
+ const msg = e instanceof Error ? e.message : String(e);
401
+ process.stderr.write(`whale mcp: server failed to start: ${msg}\n`);
402
+ process.exit(1);
403
+ }
404
+ }