@ikas/code-components-mcp 1.4.0-beta.4 → 1.4.0-beta.40
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/data/framework.json +22 -5
- package/data/migration.json +189 -24
- package/data/storefront-api.json +114 -1381
- package/data/storefront-types.json +32 -124
- package/dist/index.js +511 -92
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { execFile } from "child_process";
|
|
4
5
|
import * as fs from "fs";
|
|
6
|
+
import * as os from "os";
|
|
5
7
|
import * as path from "path";
|
|
6
8
|
import { fileURLToPath } from "url";
|
|
7
9
|
import { z } from "zod";
|
|
@@ -15,6 +17,59 @@ function loadJsonFile(relativePath) {
|
|
|
15
17
|
}
|
|
16
18
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
17
19
|
}
|
|
20
|
+
// Resolve theme.json from either raw string or absolute file path.
|
|
21
|
+
// Exactly one of the two must be provided.
|
|
22
|
+
function resolveThemeJson(themeJson, themeJsonPath) {
|
|
23
|
+
const hasInline = typeof themeJson === "string" && themeJson.length > 0;
|
|
24
|
+
const hasPath = typeof themeJsonPath === "string" && themeJsonPath.length > 0;
|
|
25
|
+
if (hasInline === hasPath) {
|
|
26
|
+
throw new Error("Provide exactly one of `theme_json` (raw JSON string) or `theme_json_path` (absolute path to theme.json).");
|
|
27
|
+
}
|
|
28
|
+
let raw;
|
|
29
|
+
if (hasPath) {
|
|
30
|
+
if (!path.isAbsolute(themeJsonPath)) {
|
|
31
|
+
throw new Error(`theme_json_path must be absolute: ${themeJsonPath}`);
|
|
32
|
+
}
|
|
33
|
+
if (!fs.existsSync(themeJsonPath)) {
|
|
34
|
+
throw new Error(`theme_json_path not found: ${themeJsonPath}`);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
raw = fs.readFileSync(themeJsonPath, "utf-8");
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
throw new Error(`Failed to read theme_json_path "${themeJsonPath}": ${e.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
raw = themeJson;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
throw new Error(`Invalid JSON in theme.json: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Atomic file write: temp file in same dir, then rename. Same-fs rename is atomic on POSIX.
|
|
54
|
+
function writeFileAtomic(targetPath, content) {
|
|
55
|
+
const dir = path.dirname(targetPath);
|
|
56
|
+
const base = path.basename(targetPath);
|
|
57
|
+
const tmp = path.join(dir, `.${base}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
|
|
58
|
+
try {
|
|
59
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
60
|
+
fs.renameSync(tmp, targetPath);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
try {
|
|
64
|
+
if (fs.existsSync(tmp))
|
|
65
|
+
fs.unlinkSync(tmp);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// ignore cleanup failure
|
|
69
|
+
}
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
18
73
|
// Try multiple paths for storefront data (generated output or local data dir)
|
|
19
74
|
function loadStorefrontData() {
|
|
20
75
|
const paths = [
|
|
@@ -506,18 +561,23 @@ function generateMigrationPlan(theme, projectName, oldSourceDir) {
|
|
|
506
561
|
}
|
|
507
562
|
const sharedSubs = oldSourceDir ? scanSharedSubcomponents(oldSourceDir) : [];
|
|
508
563
|
const parts = [];
|
|
509
|
-
parts.push(`# Theme Migration Plan
|
|
564
|
+
parts.push(`# Theme Migration Plan — \`${projectName}\``);
|
|
510
565
|
parts.push("");
|
|
511
566
|
parts.push(`**Generated:** ${new Date().toISOString().slice(0, 10)}`);
|
|
512
|
-
parts.push(`**Project ID:** \`${projectName}\``);
|
|
513
567
|
parts.push(`**Source:** ${components.length} old components, ${customData.filter(cd => cd.isRoot).length} custom data types, ${(theme.pages || []).length} pages`);
|
|
514
568
|
parts.push("");
|
|
515
|
-
parts.push(
|
|
516
|
-
parts.push(
|
|
517
|
-
parts.push(
|
|
518
|
-
parts.push(
|
|
519
|
-
parts.push(
|
|
520
|
-
parts.push(
|
|
569
|
+
parts.push(`> ## READ THIS FIRST`);
|
|
570
|
+
parts.push(`>`);
|
|
571
|
+
parts.push(`> This file is your responsibility from here. The MCP wrote this initial scaffold **once**. You own all updates — no MCP tool will modify this file again.`);
|
|
572
|
+
parts.push(`>`);
|
|
573
|
+
parts.push(`> 1. **Tick checkboxes** as you finish work. Use \`[~]\` for in-progress and \`[x]\` for done. Edit the file directly with your file-editing tools.`);
|
|
574
|
+
parts.push(`> 2. **theme.json is incomplete.** Atomic components (Button, Input, Card, icons, etc.) often live only in \`src/\` and are NOT referenced from theme.json. Before you start section migration, scan the old source directory and ADD entries to this file for anything the initial scan missed — list them under \`## Source Code Analysis\` and add shared ones to \`### Shared Sub-Components\`.`);
|
|
575
|
+
parts.push(`> 3. **Custom data types are NOT pre-converted.** For each customData entry used by a section, decide enum-vs-component when you migrate that section. Log every decision in \`## Custom Data Decisions\` with reasoning. See \`get_migration_guide("custom-data-conversion")\` for the heuristic.`);
|
|
576
|
+
parts.push(`> 4. **Preserve feature parity. Do NOT silently simplify or drop features.** If an old prop has richer fields than a new built-in prop type can carry (e.g. a navigation link with a per-link image), the answer is to build a child component — never to flatten and "add it later." Later doesn't come. If the user explicitly wants a feature removed, log that as an explicit decision in \`## Notes\`; otherwise migrate to functional parity.`);
|
|
577
|
+
parts.push(`> 5. **Per-section work:** call \`get_section_migration_plan({theme_json_path, section_name, project_name: "${projectName}"})\` for each section. The MCP returns concrete CLI commands and flags any customData-referencing props with a "Decide: enum or component?" callout.`);
|
|
578
|
+
parts.push(`> 6. **If you discover new shared sub-components mid-migration**, add them under \`### Shared Sub-Components\` and any notes under \`## Notes\`.`);
|
|
579
|
+
parts.push(`>`);
|
|
580
|
+
parts.push(`> Status legend: \`[ ]\` not started · \`[~]\` in progress · \`[x]\` complete.`);
|
|
521
581
|
parts.push("");
|
|
522
582
|
parts.push(`## Foundation`);
|
|
523
583
|
parts.push("");
|
|
@@ -537,27 +597,9 @@ function generateMigrationPlan(theme, projectName, oldSourceDir) {
|
|
|
537
597
|
parts.push(`- [ ] ${settings.fontFamily.name} (weights: ${settings.fontFamily.variants?.join(", ") || "default"})`);
|
|
538
598
|
parts.push("");
|
|
539
599
|
}
|
|
540
|
-
// Custom Enums
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
parts.push(`### Custom Enums (run these BEFORE any \`config add-component\`)`);
|
|
544
|
-
parts.push("");
|
|
545
|
-
for (const cd of enumCustomData) {
|
|
546
|
-
const enumName = cd.typescriptName || (cd.name ? cd.name.replace(/[^a-zA-Z0-9]/g, "") : "Enum");
|
|
547
|
-
const options = (cd.enumOptions || []).reduce((acc, o) => {
|
|
548
|
-
if (o.displayName && o.value)
|
|
549
|
-
acc[o.displayName] = o.value;
|
|
550
|
-
return acc;
|
|
551
|
-
}, {});
|
|
552
|
-
const optionsStr = JSON.stringify(options);
|
|
553
|
-
parts.push(`- [ ] **${enumName}**`);
|
|
554
|
-
parts.push(` \`\`\`bash`);
|
|
555
|
-
parts.push(` npx ikas-component config add-enum --name "${enumName}" --options '${optionsStr}'`);
|
|
556
|
-
parts.push(` \`\`\``);
|
|
557
|
-
parts.push(` Save the returned \`enumId\` and update this file with: \`enumId: <id>\``);
|
|
558
|
-
}
|
|
559
|
-
parts.push("");
|
|
560
|
-
}
|
|
600
|
+
// (Custom Enums foundation subsection intentionally removed — see ## Custom Data Types below.
|
|
601
|
+
// Old customData entries are NOT pre-migrated as enums; each is a per-section decision.
|
|
602
|
+
// See get_migration_guide("custom-data-conversion") for the enum-vs-component heuristic.)
|
|
561
603
|
// Shared sub-components
|
|
562
604
|
if (sharedSubs.length > 0) {
|
|
563
605
|
parts.push(`### Shared Sub-Components (→ \`src/sub-components/\`)`);
|
|
@@ -581,6 +623,51 @@ function generateMigrationPlan(theme, projectName, oldSourceDir) {
|
|
|
581
623
|
parts.push(`_Old source directory not provided — shared sub-components cannot be detected automatically. If you have access to the old source, call \`plan_migration\` again with \`old_source_dir\` to populate this section._`);
|
|
582
624
|
parts.push("");
|
|
583
625
|
}
|
|
626
|
+
parts.push(`> **This scan is partial.** \`scanSharedSubcomponents\` only detects relative imports used by **3 or more** old components. Atomic components used by fewer sections (often Button/Input/Card/icon primitives) will be missed. Scan \`src/\` yourself and add any others under \`## Source Code Analysis\` below.`);
|
|
627
|
+
parts.push("");
|
|
628
|
+
// Custom Data Types — deferred decisions (reference list, not checkboxes)
|
|
629
|
+
const rootCustomData = customData.filter((cd) => cd.isRoot);
|
|
630
|
+
if (rootCustomData.length > 0) {
|
|
631
|
+
// Build a usage map: which sections reference each customData type
|
|
632
|
+
const usageByCustomDataId = new Map();
|
|
633
|
+
for (const comp of components) {
|
|
634
|
+
const sectionName = comp.displayName || comp.dir || comp.id || "?";
|
|
635
|
+
for (const p of comp.props || []) {
|
|
636
|
+
if (p.type === "CUSTOM" && p.customDataId) {
|
|
637
|
+
const list = usageByCustomDataId.get(p.customDataId) || [];
|
|
638
|
+
if (!list.includes(sectionName))
|
|
639
|
+
list.push(sectionName);
|
|
640
|
+
usageByCustomDataId.set(p.customDataId, list);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
parts.push(`### Custom Data Types — decide during section migration`);
|
|
645
|
+
parts.push("");
|
|
646
|
+
parts.push(`Each entry below is an OLD \`theme.customData[]\` type. Do **not** pre-migrate these as enums. When you migrate the section that uses one, decide:`);
|
|
647
|
+
parts.push(`- **Flat scalar set** (e.g. \`"left" | "right" | "center"\`) → new-system **enum prop** via \`config add-enum\`.`);
|
|
648
|
+
parts.push(`- **Structured record** (e.g. \`{image, link, title}\`, repeated in a list) → new-system **component** via \`config add-component\` + COMPONENT_LIST wiring on the parent.`);
|
|
649
|
+
parts.push("");
|
|
650
|
+
parts.push(`Log every decision in \`## Custom Data Decisions\` below. See \`get_migration_guide("custom-data-conversion")\` for the full heuristic and worked examples (Position, Slide, MenuItem).`);
|
|
651
|
+
parts.push("");
|
|
652
|
+
for (const cd of rootCustomData) {
|
|
653
|
+
const cdName = cd.typescriptName || cd.name || cd.id || "Unknown";
|
|
654
|
+
const cdType = cd.type || "?";
|
|
655
|
+
let shape = "";
|
|
656
|
+
if (cd.type === "ENUM") {
|
|
657
|
+
const opts = (cd.enumOptions || []).map((o) => o.value || o.displayName).filter(Boolean);
|
|
658
|
+
shape = ` — shape: \`enum {${opts.slice(0, 6).join(", ")}${opts.length > 6 ? ", ..." : ""}}\``;
|
|
659
|
+
}
|
|
660
|
+
else if (cd.nestedData && cd.nestedData.length > 0) {
|
|
661
|
+
const first = cd.nestedData[0];
|
|
662
|
+
const fields = (first?.nestedData || cd.nestedData || []).map((f) => `${f.key || f.name || "?"}: ${f.type || "?"}`).slice(0, 8);
|
|
663
|
+
shape = ` — shape: \`{${fields.join(", ")}}\``;
|
|
664
|
+
}
|
|
665
|
+
const usedBy = cd.id ? usageByCustomDataId.get(cd.id) || [] : [];
|
|
666
|
+
const usedByStr = usedBy.length > 0 ? ` — used by: ${usedBy.slice(0, 6).join(", ")}${usedBy.length > 6 ? `, +${usedBy.length - 6} more` : ""}` : ` — _not directly referenced by any section's props (may be nested inside another customData)_`;
|
|
667
|
+
parts.push(`- \`${cdName}\` (${cdType})${shape}${usedByStr}`);
|
|
668
|
+
}
|
|
669
|
+
parts.push("");
|
|
670
|
+
}
|
|
584
671
|
// Sections queue
|
|
585
672
|
parts.push(`## Sections`);
|
|
586
673
|
parts.push("");
|
|
@@ -638,49 +725,43 @@ function generateMigrationPlan(theme, projectName, oldSourceDir) {
|
|
|
638
725
|
}
|
|
639
726
|
parts.push("");
|
|
640
727
|
}
|
|
641
|
-
//
|
|
642
|
-
parts.push(`##
|
|
728
|
+
// Source Code Analysis — placeholder for the LLM to fill in
|
|
729
|
+
parts.push(`## Source Code Analysis`);
|
|
643
730
|
parts.push("");
|
|
644
|
-
parts.push(
|
|
731
|
+
parts.push(`> **Before starting section migration**, scan the old \`src/\` for components NOT listed above. theme.json does not see atomic primitives — buttons, inputs, cards, icon wrappers, layout helpers, etc. — that are imported by sections but never appear as theme components. Add them here, then decide which become shared sub-components vs which collapse into section bodies.`);
|
|
645
732
|
parts.push("");
|
|
646
|
-
parts.push(
|
|
733
|
+
parts.push(`<!-- Example: \`- Button (src/atoms/Button/) — used by ~all sections → promote to Shared Sub-Component\` -->`);
|
|
734
|
+
parts.push("");
|
|
735
|
+
// Custom Data Decisions — append-only log the LLM fills as it makes per-section decisions
|
|
736
|
+
parts.push(`## Custom Data Decisions`);
|
|
737
|
+
parts.push("");
|
|
738
|
+
parts.push(`> Log each customData enum-vs-component decision here as you encounter it during section migration. Format: \`- \\\`<CustomDataName>\\\` → enum/component \\\`<target name>\\\` (YYYY-MM-DD) — reasoning\`.`);
|
|
739
|
+
parts.push("");
|
|
740
|
+
parts.push(`<!-- Example: \`- SlideData → component \\\`hero-slide\\\` (2026-05-11) — structured record {image,link,title}; LIST_OF_LINK would drop the image\` -->`);
|
|
647
741
|
parts.push("");
|
|
648
|
-
//
|
|
649
|
-
parts.push(`##
|
|
742
|
+
// Known Environmental Issues (agents fill in during work)
|
|
743
|
+
parts.push(`## Known Environmental Issues`);
|
|
650
744
|
parts.push("");
|
|
651
|
-
parts.push(`
|
|
745
|
+
parts.push(`_Record any non-component build/TS errors here so future sessions don't waste time diagnosing them._`);
|
|
652
746
|
parts.push("");
|
|
653
|
-
parts.push(
|
|
654
|
-
parts.push(`2. Call \`get_section_migration_plan(theme_json, "<old section name>", "${projectName}")\`.`);
|
|
655
|
-
parts.push(`3. Read the old source files listed in the plan.`);
|
|
656
|
-
parts.push(`4. Run the CLI commands in the plan (they create parent + children with auto-generated types.ts).`);
|
|
657
|
-
parts.push(`5. Write \`index.tsx\` and \`styles.css\` using the patterns in the plan. DO NOT manually edit types.ts.`);
|
|
658
|
-
parts.push(`6. Mark the section \`[x]\` when it builds cleanly. Append a short note to the **Session Log** below (what libraries you replaced, any ad-hoc props you had to add, the actual folder name the CLI created if different from the name you passed).`);
|
|
747
|
+
parts.push(`- [ ] _(none recorded yet)_`);
|
|
659
748
|
parts.push("");
|
|
660
|
-
//
|
|
661
|
-
parts.push(`##
|
|
749
|
+
// Notes — append-only log for decisions not captured elsewhere
|
|
750
|
+
parts.push(`## Notes`);
|
|
662
751
|
parts.push("");
|
|
663
|
-
parts.push(`
|
|
752
|
+
parts.push(`_Append a bullet after completing work — library swaps, ad-hoc props, CLI folder-name oddities, user-approved feature drops, etc. Format: \`- [YYYY-MM-DD] <section-id>: <brief note>\`._`);
|
|
664
753
|
parts.push("");
|
|
665
|
-
parts.push(
|
|
666
|
-
parts.push(`<!-- \`- [2026-04-15] Foundation: built Input, SubmitButton, Modal, SectionHeader, StarRating. Skipped: GoogleCaptcha (needs investigation), BlogCard, Pagination (low priority).\` -->`);
|
|
667
|
-
parts.push(`<!-- \`- [2026-04-15] ${projectName}-footer: swapped react-hot-toast for inline status text; newsletter uses form-model pattern (getNewsletterSubscriptionForm). CLI created folder as "Footer/" as expected.\` -->`);
|
|
754
|
+
parts.push(`<!-- Example: \`- [2026-04-15] ${projectName}-footer: swapped react-hot-toast for inline status text; CLI created Footer/ as expected\` -->`);
|
|
668
755
|
parts.push("");
|
|
669
|
-
// Cross-references
|
|
756
|
+
// Cross-references — keep terse; LLM can call `get_migration_guide("list")` or `get_framework_guide("list")` for more
|
|
670
757
|
parts.push(`## Cross-References`);
|
|
671
758
|
parts.push("");
|
|
672
|
-
parts.push(
|
|
759
|
+
parts.push(`- \`get_migration_guide("iterative-workflow")\` — the full per-phase protocol`);
|
|
760
|
+
parts.push(`- \`get_migration_guide("custom-data-conversion")\` — enum-vs-component decisions`);
|
|
761
|
+
parts.push(`- \`get_migration_guide("prop-runtime-shapes")\` — runtime shapes & access patterns`);
|
|
762
|
+
parts.push(`- \`get_framework_guide("common-pitfalls")\` — gotchas & old→new property migrations`);
|
|
673
763
|
parts.push("");
|
|
674
|
-
parts.push(
|
|
675
|
-
parts.push(`- \`get_migration_guide("component-renderer-limitations")\` — critical constraints when using COMPONENT_LIST`);
|
|
676
|
-
parts.push(`- \`get_migration_guide("prop-runtime-shapes")\` — \`.data\` vs \`.links\`, \`.variant\` vs \`.product\`, etc.`);
|
|
677
|
-
parts.push(`- \`get_migration_guide("link-prop-decision-guide")\` — LINK vs LIST_OF_LINK vs COMPONENT_LIST`);
|
|
678
|
-
parts.push(`- \`get_migration_guide("library-replacements")\` — swiper, headlessui, tailwind, etc.`);
|
|
679
|
-
parts.push(`- \`get_migration_guide("react-to-preact")\` — observer rules, imports, IkasSlider removal`);
|
|
680
|
-
parts.push(`- \`get_framework_guide("component-renderer-patterns")\` — full IkasComponentRenderer usage`);
|
|
681
|
-
parts.push(`- \`get_framework_guide("common-pitfalls")\` — general gotchas`);
|
|
682
|
-
parts.push(`- \`get_framework_guide("navigation-patterns")\` — Router.navigate, Router.navigateToPage`);
|
|
683
|
-
parts.push(`- \`get_model_guide("<TypeName>")\` — IkasCart, IkasProduct, IkasCustomer shapes`);
|
|
764
|
+
parts.push(`Call \`get_migration_guide("list")\` or \`get_framework_guide("list")\` for the full topic catalog.`);
|
|
684
765
|
parts.push("");
|
|
685
766
|
return parts.join("\n");
|
|
686
767
|
}
|
|
@@ -1057,6 +1138,119 @@ function generateSectionMigrationPlan(theme, sectionName, projectName, oldSource
|
|
|
1057
1138
|
parts.push(`| \`${oldName}\` | ${oldType} | → | \`${newName}\` | ${newType} | ${notes} |`);
|
|
1058
1139
|
}
|
|
1059
1140
|
parts.push("");
|
|
1141
|
+
// Custom Data Decision Callouts — per prop referencing a customData type
|
|
1142
|
+
const customDataPropsForCallouts = (target.props || []).filter((p) => p.type === "CUSTOM" && p.customDataId && customDataMap.has(p.customDataId));
|
|
1143
|
+
if (customDataPropsForCallouts.length > 0) {
|
|
1144
|
+
parts.push(`## Custom Data Decisions to Make`);
|
|
1145
|
+
parts.push("");
|
|
1146
|
+
parts.push(`Each prop below references an old \`customData\` type. Verify the MCP's default against the actual data semantics, then run the CLI command. **Log every decision in MIGRATION.md → \`## Custom Data Decisions\`** with reasoning. See \`get_migration_guide("custom-data-conversion")\` for the heuristic and worked examples.`);
|
|
1147
|
+
parts.push("");
|
|
1148
|
+
for (const p of customDataPropsForCallouts) {
|
|
1149
|
+
const cd = customDataMap.get(p.customDataId);
|
|
1150
|
+
if (!cd)
|
|
1151
|
+
continue;
|
|
1152
|
+
const cdName = cd.typescriptName || cd.name || cd.id || "Unknown";
|
|
1153
|
+
const cdType = cd.type || "?";
|
|
1154
|
+
let shape = "";
|
|
1155
|
+
let shapeKind = "unknown";
|
|
1156
|
+
if (cd.type === "ENUM") {
|
|
1157
|
+
const opts = (cd.enumOptions || []).map((o) => o.value || o.displayName).filter(Boolean);
|
|
1158
|
+
shape = `enum {${opts.slice(0, 6).join(", ")}${opts.length > 6 ? ", ..." : ""}}`;
|
|
1159
|
+
shapeKind = "enum";
|
|
1160
|
+
}
|
|
1161
|
+
else if (cd.type === "DYNAMIC_LIST" || cd.type === "STATIC_LIST") {
|
|
1162
|
+
const first = cd.nestedData?.[0];
|
|
1163
|
+
const fields = (first?.nestedData || []).map((f) => `${f.key || f.name || "?"}: ${f.type || "?"}`);
|
|
1164
|
+
shape = `${cdType} of {${fields.slice(0, 6).join(", ")}}`;
|
|
1165
|
+
shapeKind = "list";
|
|
1166
|
+
}
|
|
1167
|
+
else if (cd.type === "OBJECT") {
|
|
1168
|
+
const fields = (cd.nestedData || []).map((f) => `${f.key || f.name || "?"}: ${f.type || "?"}`);
|
|
1169
|
+
shape = `OBJECT {${fields.slice(0, 6).join(", ")}}`;
|
|
1170
|
+
shapeKind = "record";
|
|
1171
|
+
}
|
|
1172
|
+
else {
|
|
1173
|
+
shape = cdType;
|
|
1174
|
+
}
|
|
1175
|
+
// Build the field source + names list once so we can use it for the lost-fields enumeration
|
|
1176
|
+
// AND for the component-path CLI template.
|
|
1177
|
+
const fieldSource = cd.type === "DYNAMIC_LIST" || cd.type === "STATIC_LIST"
|
|
1178
|
+
? cd.nestedData?.[0]?.nestedData || []
|
|
1179
|
+
: cd.nestedData || [];
|
|
1180
|
+
const fieldDescriptions = fieldSource
|
|
1181
|
+
.filter((f) => f.key)
|
|
1182
|
+
.map((f) => `\`${f.key}\` (${f.type || "?"})`);
|
|
1183
|
+
parts.push(`### Prop \`${p.name || "?"}\` → customData \`${cdName}\``);
|
|
1184
|
+
parts.push("");
|
|
1185
|
+
parts.push(`**Shape:** \`${shape}\``);
|
|
1186
|
+
parts.push("");
|
|
1187
|
+
if (shapeKind === "enum") {
|
|
1188
|
+
parts.push(`**Default: enum prop.** Flat scalar set; use \`config add-enum\`.`);
|
|
1189
|
+
parts.push("");
|
|
1190
|
+
const enumName = cd.typescriptName || (cd.name ? cd.name.replace(/[^a-zA-Z0-9]/g, "") : "Enum");
|
|
1191
|
+
const enumOptions = (cd.enumOptions || []).reduce((acc, o) => {
|
|
1192
|
+
if (o.displayName && o.value)
|
|
1193
|
+
acc[o.displayName] = o.value;
|
|
1194
|
+
return acc;
|
|
1195
|
+
}, {});
|
|
1196
|
+
parts.push("```bash");
|
|
1197
|
+
parts.push(`npx ikas-component config add-enum --name "${enumName}" --options '${JSON.stringify(enumOptions)}'`);
|
|
1198
|
+
parts.push("```");
|
|
1199
|
+
parts.push("");
|
|
1200
|
+
parts.push(`If you believe this should be a component instead (e.g. each option secretly carries richer data not visible in the customData shape), see \`get_migration_guide("custom-data-conversion")\`.`);
|
|
1201
|
+
parts.push("");
|
|
1202
|
+
}
|
|
1203
|
+
else if (shapeKind === "list" || shapeKind === "record") {
|
|
1204
|
+
const fieldCount = fieldDescriptions.length;
|
|
1205
|
+
const isMinimal = fieldCount > 0 && fieldCount <= 2;
|
|
1206
|
+
if (isMinimal) {
|
|
1207
|
+
parts.push(`⚠️ **This child would have only ${fieldCount} field${fieldCount === 1 ? "" : "s"}** (${fieldDescriptions.join(", ")}). \`COMPONENT_LIST\` is usually overkill at this size. **Prefer one of:**`);
|
|
1208
|
+
parts.push(`- repeated scalar props on the parent (\`title1\`/\`link1\`, \`title2\`/\`link2\`, …) for a small fixed count`);
|
|
1209
|
+
parts.push(`- a domain LIST prop type (\`LIST_OF_LINK\`, \`IMAGE_LIST\`, \`PRODUCT_LIST\`, …) when each item IS one domain object`);
|
|
1210
|
+
parts.push(`- \`COMPONENT_LIST\` (CLI command below) only if reordering in the editor is a real UX win`);
|
|
1211
|
+
parts.push("");
|
|
1212
|
+
parts.push(`See \`get_migration_guide("component-composition-decision-guide")\` for the full tree. Log your choice in MIGRATION.md → \`## Custom Data Decisions\`.`);
|
|
1213
|
+
parts.push("");
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
parts.push(`**Default: component + COMPONENT_LIST.** Multiple fields per item — a single enum value cannot carry this structure.`);
|
|
1217
|
+
parts.push("");
|
|
1218
|
+
if (fieldCount > 0) {
|
|
1219
|
+
parts.push(`⚠️ **Fields you would lose if you flatten this:** ${fieldDescriptions.join(", ")}. Flattening to a simpler prop type drops these from the editor UI permanently. **Do not "simplify for later"** — if the feature genuinely isn't wanted, log that explicitly in MIGRATION.md → \`## Notes\` with reasoning. Otherwise build the component.`);
|
|
1220
|
+
parts.push("");
|
|
1221
|
+
}
|
|
1222
|
+
parts.push(`> See \`get_migration_guide("component-composition-decision-guide")\` for when \`COMPONENT_LIST\` is overkill.`);
|
|
1223
|
+
parts.push("");
|
|
1224
|
+
}
|
|
1225
|
+
const compName = cd.typescriptName || (cd.name ? cd.name.replace(/[^a-zA-Z0-9]/g, "") : `${sectionPascal}Item`);
|
|
1226
|
+
const compPropsForCli = [];
|
|
1227
|
+
for (const f of fieldSource) {
|
|
1228
|
+
if (!f.key)
|
|
1229
|
+
continue;
|
|
1230
|
+
let fType = f.type;
|
|
1231
|
+
if (fType === "SLIDER")
|
|
1232
|
+
fType = "NUMBER";
|
|
1233
|
+
else if (fType === "PRODUCT_DETAIL")
|
|
1234
|
+
fType = "PRODUCT";
|
|
1235
|
+
compPropsForCli.push({ name: f.key, displayName: f.name || f.key, type: fType });
|
|
1236
|
+
}
|
|
1237
|
+
parts.push("```bash");
|
|
1238
|
+
if (isMinimal) {
|
|
1239
|
+
parts.push(`# Fallback: COMPONENT_LIST (use only if the simpler alternatives above don't fit)`);
|
|
1240
|
+
}
|
|
1241
|
+
parts.push(`npx ikas-component config add-component --name "${compName}" --type component --props '${JSON.stringify(compPropsForCli)}'`);
|
|
1242
|
+
parts.push(`# Then on the parent, set the prop's filteredComponentIds to the new component's id.`);
|
|
1243
|
+
parts.push("```");
|
|
1244
|
+
parts.push("");
|
|
1245
|
+
parts.push(`If you believe this should be an enum despite the structure, see \`get_migration_guide("custom-data-conversion")\`.`);
|
|
1246
|
+
parts.push("");
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
parts.push(`**Unable to classify automatically — you decide.** See \`get_migration_guide("custom-data-conversion")\` for the enum-vs-component heuristic.`);
|
|
1250
|
+
parts.push("");
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1060
1254
|
// Enums to create first
|
|
1061
1255
|
if (enumsNeeded.length > 0) {
|
|
1062
1256
|
parts.push(`## 3. Create Enums FIRST (if not already done)`);
|
|
@@ -1222,30 +1416,19 @@ function generateSectionMigrationPlan(theme, sectionName, projectName, oldSource
|
|
|
1222
1416
|
parts.push("");
|
|
1223
1417
|
}
|
|
1224
1418
|
}
|
|
1225
|
-
// Relevant guides
|
|
1226
|
-
parts.push(`## ${nextStep + 1}. Relevant Guides
|
|
1419
|
+
// Relevant guides — keep terse; LLM can call get_migration_guide("list") for the full catalog
|
|
1420
|
+
parts.push(`## ${nextStep + 1}. Relevant Guides`);
|
|
1227
1421
|
parts.push("");
|
|
1228
1422
|
parts.push(`- \`get_migration_guide("react-to-preact")\` — code conversion patterns`);
|
|
1229
|
-
parts.push(`- \`get_migration_guide("
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
parts.push(`- \`get_migration_guide("component-renderer-limitations")\` — critical COMPONENT_LIST constraints`);
|
|
1233
|
-
parts.push(`- \`get_framework_guide("component-renderer-patterns")\` — full IkasComponentRenderer usage`);
|
|
1234
|
-
parts.push(`- \`get_migration_example("custom-dynamic-list-to-component-list")\` — concrete example`);
|
|
1235
|
-
}
|
|
1236
|
-
if (target.isHeader || target.isFooter) {
|
|
1237
|
-
parts.push(`- \`get_framework_guide("header-footer-patterns")\` — header/footer specifics`);
|
|
1423
|
+
parts.push(`- \`get_migration_guide("prop-runtime-shapes")\` — exact runtime shapes (\`.data\` vs \`.links\`, etc.)`);
|
|
1424
|
+
if (children.length > 0 || target.isHeader || target.isFooter) {
|
|
1425
|
+
parts.push(`- \`get_framework_guide("header-footer-patterns")\` — COMPONENT_LIST + IkasComponentRenderer wiring`);
|
|
1238
1426
|
}
|
|
1239
|
-
parts.push(`- \`get_framework_guide("common-pitfalls")\` — observer rules, common mistakes`);
|
|
1240
1427
|
parts.push("");
|
|
1241
1428
|
// Completion
|
|
1242
1429
|
parts.push(`## ${nextStep + 2}. Mark Complete`);
|
|
1243
1430
|
parts.push("");
|
|
1244
|
-
parts.push(`Once the section builds cleanly with \`npx ikas-component build
|
|
1245
|
-
parts.push(`1. Edit \`MIGRATION.md\` at the project root`);
|
|
1246
|
-
parts.push(`2. Change the checkbox for \`${sectionId}\` from \`[ ]\` to \`[x]\``);
|
|
1247
|
-
parts.push(`3. Also mark each child component as \`[x]\``);
|
|
1248
|
-
parts.push(`4. Append an entry to the **Session Log** section of \`MIGRATION.md\` noting: libraries replaced, any props added beyond the generated plan, the actual folder name the CLI created (in case it differs from the name you passed — e.g., \`FAQ\` → \`Faq/\`), and any other decisions a future agent should know.`);
|
|
1431
|
+
parts.push(`Once the section builds cleanly with \`npx ikas-component build\`: edit MIGRATION.md → tick \`[x]\` for \`${sectionId}\` and each child component, log any customData decisions under \`## Custom Data Decisions\`, and append a brief entry to \`## Notes\` with anything future sessions should know.`);
|
|
1249
1432
|
parts.push("");
|
|
1250
1433
|
return parts.join("\n");
|
|
1251
1434
|
}
|
|
@@ -1271,6 +1454,54 @@ function matchScore(text, query) {
|
|
|
1271
1454
|
}
|
|
1272
1455
|
return score;
|
|
1273
1456
|
}
|
|
1457
|
+
// Levenshtein edit distance — small DP, fine for the 28 short kebab-case section template names.
|
|
1458
|
+
function levenshtein(a, b) {
|
|
1459
|
+
if (a === b)
|
|
1460
|
+
return 0;
|
|
1461
|
+
if (a.length === 0)
|
|
1462
|
+
return b.length;
|
|
1463
|
+
if (b.length === 0)
|
|
1464
|
+
return a.length;
|
|
1465
|
+
const m = a.length;
|
|
1466
|
+
const n = b.length;
|
|
1467
|
+
const prev = new Array(n + 1);
|
|
1468
|
+
const curr = new Array(n + 1);
|
|
1469
|
+
for (let j = 0; j <= n; j++)
|
|
1470
|
+
prev[j] = j;
|
|
1471
|
+
for (let i = 1; i <= m; i++) {
|
|
1472
|
+
curr[0] = i;
|
|
1473
|
+
for (let j = 1; j <= n; j++) {
|
|
1474
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
1475
|
+
curr[j] = Math.min(curr[j - 1] + 1, // insertion
|
|
1476
|
+
prev[j] + 1, // deletion
|
|
1477
|
+
prev[j - 1] + cost // substitution
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
for (let j = 0; j <= n; j++)
|
|
1481
|
+
prev[j] = curr[j];
|
|
1482
|
+
}
|
|
1483
|
+
return prev[n];
|
|
1484
|
+
}
|
|
1485
|
+
// Suggest up to `max` closest candidates for an unknown input.
|
|
1486
|
+
// Primary: matchScore (substring/word). Fallback: Levenshtein when nothing scores well.
|
|
1487
|
+
function suggestClosestNames(input, candidates, opts) {
|
|
1488
|
+
const min = opts?.min ?? 8;
|
|
1489
|
+
const max = opts?.max ?? 3;
|
|
1490
|
+
const scored = candidates
|
|
1491
|
+
.map((c) => ({ name: c, score: matchScore(c, input) }))
|
|
1492
|
+
.filter((s) => s.score >= min)
|
|
1493
|
+
.sort((a, b) => b.score - a.score);
|
|
1494
|
+
if (scored.length > 0)
|
|
1495
|
+
return scored.slice(0, max).map((s) => s.name);
|
|
1496
|
+
// Levenshtein fallback — tolerate up to ceil(len/3) edits.
|
|
1497
|
+
const maxDistance = Math.max(1, Math.ceil(input.length / 3));
|
|
1498
|
+
const lower = input.toLowerCase();
|
|
1499
|
+
const edits = candidates
|
|
1500
|
+
.map((c) => ({ name: c, d: levenshtein(lower, c.toLowerCase()) }))
|
|
1501
|
+
.filter((s) => s.d <= maxDistance)
|
|
1502
|
+
.sort((a, b) => a.d - b.d);
|
|
1503
|
+
return edits.slice(0, max).map((s) => s.name);
|
|
1504
|
+
}
|
|
1274
1505
|
function searchFunctions(query) {
|
|
1275
1506
|
const scored = storefrontData.functions
|
|
1276
1507
|
.map((fn) => {
|
|
@@ -2141,11 +2372,19 @@ server.tool("get_section_template", "Get the root files of a starter section tem
|
|
|
2141
2372
|
}
|
|
2142
2373
|
const normalizedType = normalizeName(sectionType);
|
|
2143
2374
|
if (!sectionTemplateNames.includes(normalizedType)) {
|
|
2375
|
+
const suggestions = suggestClosestNames(normalizedType, sectionTemplateNames);
|
|
2376
|
+
let suggestionText = "";
|
|
2377
|
+
if (suggestions.length === 1) {
|
|
2378
|
+
suggestionText = ` Did you mean "${suggestions[0]}"?`;
|
|
2379
|
+
}
|
|
2380
|
+
else if (suggestions.length > 1) {
|
|
2381
|
+
suggestionText = ` Did you mean one of: ${suggestions.map((s) => `"${s}"`).join(", ")}?`;
|
|
2382
|
+
}
|
|
2144
2383
|
return {
|
|
2145
2384
|
content: [
|
|
2146
2385
|
{
|
|
2147
2386
|
type: "text",
|
|
2148
|
-
text: `Unknown section type "${sectionType}"
|
|
2387
|
+
text: `Unknown section type "${sectionType}".${suggestionText} Call \`list_section_types()\` to see all ${sectionTemplateNames.length} valid types.`,
|
|
2149
2388
|
},
|
|
2150
2389
|
],
|
|
2151
2390
|
};
|
|
@@ -2602,7 +2841,7 @@ const migrationTopicAliases = {
|
|
|
2602
2841
|
const migrationTopicKeys = migrationData
|
|
2603
2842
|
? Object.keys(migrationData.topics)
|
|
2604
2843
|
: [];
|
|
2605
|
-
server.tool("get_migration_guide", `Get a migration guide for converting old ikas themes to the new code-component system.${migrationTopicKeys.length > 0 ? ` Available topics: ${migrationTopicKeys.join(", ")}. Also supports aliases like "custom", "slider", "react", "libraries", "imports", "settings".` : ""} Call with topic "list" to see all available topics.`, { topic: z.string().describe("Migration topic key, alias, or 'list' to see all topics") }, async ({ topic }) => {
|
|
2844
|
+
server.tool("get_migration_guide", `Get a migration guide for converting old ikas themes to the new code-component system. **Start with \`get_migration_guide("iterative-workflow")\` if you're new to this MCP** — it explains the MCP-vs-LLM responsibility split and the four phases.${migrationTopicKeys.length > 0 ? ` Available topics: ${migrationTopicKeys.join(", ")}. Also supports aliases like "custom", "slider", "react", "libraries", "imports", "settings".` : ""} Call with topic "list" to see all available topics.`, { topic: z.string().describe("Migration topic key, alias, or 'list' to see all topics") }, async ({ topic }) => {
|
|
2606
2845
|
if (!migrationData) {
|
|
2607
2846
|
return { content: [{ type: "text", text: "Migration data not available. Ensure data/migration.json exists." }] };
|
|
2608
2847
|
}
|
|
@@ -2686,42 +2925,222 @@ server.tool("get_migration_example", `Get a concrete before/after migration exam
|
|
|
2686
2925
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
2687
2926
|
});
|
|
2688
2927
|
// Tool: plan_migration
|
|
2689
|
-
server.tool("plan_migration", "Generate
|
|
2690
|
-
theme_json: z.string().describe("Raw JSON content of the old theme.json"),
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2928
|
+
server.tool("plan_migration", "Generate the **initial** migration plan and (when `project_root` is provided) write it to <project_root>/MIGRATION.md. **This is the only time the MCP writes that file.** From here, you own it: tick checkboxes as you finish work, log custom-data decisions, scan the old source for atomic components (Button, Input, Card, etc.) that theme.json doesn't see, and append them to MIGRATION.md yourself. theme.json is incomplete by design — the MCP can only describe what's listed there. Pass `theme_json_path` for large themes (raw `theme_json` string is supported for backward compat but fails on real-world sizes).", {
|
|
2929
|
+
theme_json: z.string().optional().describe("Raw JSON content of the old theme.json. EITHER this OR theme_json_path is required (not both). For real themes use theme_json_path — raw strings exceed tool/context limits at production sizes."),
|
|
2930
|
+
theme_json_path: z.string().optional().describe("Absolute path to the old theme.json file on disk. Preferred for any real-world theme."),
|
|
2931
|
+
project_name: z.string().optional().describe("Target new project name, used to prefix migration-tracking IDs (default: 'my-theme')"),
|
|
2932
|
+
old_source_dir: z.string().optional().describe("Absolute path to the old project's src/ directory. When provided, the tool scans .tsx files to detect shared sub-components used across 3+ components. This scan is partial — atomic components used by only 1-2 sections will be missed and must be added by the LLM."),
|
|
2933
|
+
project_root: z.string().optional().describe("Absolute path to the new project root. When provided, the MCP writes MIGRATION.md to <project_root>/MIGRATION.md and returns a short summary instead of the full markdown body."),
|
|
2934
|
+
overwrite: z.boolean().optional().describe("If MIGRATION.md already exists at <project_root>/MIGRATION.md and is non-empty, refuse the write unless this is true. Default: false."),
|
|
2935
|
+
}, async ({ theme_json, theme_json_path, project_name, old_source_dir, project_root, overwrite }) => {
|
|
2694
2936
|
try {
|
|
2695
|
-
const parsed =
|
|
2937
|
+
const parsed = resolveThemeJson(theme_json, theme_json_path);
|
|
2696
2938
|
const projectName = project_name || "my-theme";
|
|
2697
2939
|
const plan = generateMigrationPlan(parsed, projectName, old_source_dir);
|
|
2698
|
-
|
|
2940
|
+
if (!project_root) {
|
|
2941
|
+
return { content: [{ type: "text", text: plan }] };
|
|
2942
|
+
}
|
|
2943
|
+
// Write MIGRATION.md to project_root
|
|
2944
|
+
if (!path.isAbsolute(project_root)) {
|
|
2945
|
+
throw new Error(`project_root must be absolute: ${project_root}`);
|
|
2946
|
+
}
|
|
2947
|
+
if (!fs.existsSync(project_root)) {
|
|
2948
|
+
throw new Error(`project_root not found: ${project_root}`);
|
|
2949
|
+
}
|
|
2950
|
+
if (!fs.statSync(project_root).isDirectory()) {
|
|
2951
|
+
throw new Error(`project_root is not a directory: ${project_root}`);
|
|
2952
|
+
}
|
|
2953
|
+
const targetPath = path.join(project_root, "MIGRATION.md");
|
|
2954
|
+
if (fs.existsSync(targetPath)) {
|
|
2955
|
+
const existing = fs.readFileSync(targetPath, "utf-8").trim();
|
|
2956
|
+
if (existing.length > 0 && !overwrite) {
|
|
2957
|
+
return {
|
|
2958
|
+
content: [
|
|
2959
|
+
{
|
|
2960
|
+
type: "text",
|
|
2961
|
+
text: `Refusing to overwrite existing non-empty MIGRATION.md at ${targetPath}. ` +
|
|
2962
|
+
`Pass overwrite: true to replace it, or delete the file first. ` +
|
|
2963
|
+
`If you intended to RESUME an in-progress migration, do not call plan_migration again — read the existing MIGRATION.md and continue from the first unchecked item.`,
|
|
2964
|
+
},
|
|
2965
|
+
],
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
writeFileAtomic(targetPath, plan);
|
|
2970
|
+
const components = parsed.components || [];
|
|
2971
|
+
const customData = parsed.customData || [];
|
|
2972
|
+
const cssVarCount = parsed.settings?.colors?.length || 0;
|
|
2973
|
+
const fontCount = parsed.settings?.fontFamily?.name ? 1 : 0;
|
|
2974
|
+
const customDataCount = customData.filter((cd) => cd.isRoot).length;
|
|
2975
|
+
const sectionCount = components.length;
|
|
2976
|
+
const summary = [
|
|
2977
|
+
`Wrote initial migration plan to ${targetPath}`,
|
|
2978
|
+
"",
|
|
2979
|
+
`**Summary:**`,
|
|
2980
|
+
`- Sections to migrate: ${sectionCount}`,
|
|
2981
|
+
`- CSS variables: ${cssVarCount}`,
|
|
2982
|
+
`- Fonts: ${fontCount}`,
|
|
2983
|
+
`- Custom data types (deferred decisions, not pre-migrated): ${customDataCount}`,
|
|
2984
|
+
"",
|
|
2985
|
+
`**Next steps for the LLM:**`,
|
|
2986
|
+
`1. Read \`${targetPath}\` start-to-finish. The "READ THIS FIRST" preamble explains your responsibilities.`,
|
|
2987
|
+
`2. Scan the old source directory (\`${old_source_dir || "<old-src>"}\`) for atomic components (Button, Input, Card, icons, etc.) NOT referenced from theme.json. Add them to \`## Source Code Analysis\` in MIGRATION.md.`,
|
|
2988
|
+
`3. Start the Foundation work (CSS variables, fonts, shared sub-components).`,
|
|
2989
|
+
`4. For each section, call \`get_section_migration_plan({theme_json_path, section_name, project_name})\`. The MCP will tell you which props need enum-vs-component decisions.`,
|
|
2990
|
+
`5. Log every custom-data decision in \`## Custom Data Decisions\`. Tick checkboxes as you finish.`,
|
|
2991
|
+
].join("\n");
|
|
2992
|
+
return { content: [{ type: "text", text: summary }] };
|
|
2699
2993
|
}
|
|
2700
2994
|
catch (err) {
|
|
2701
2995
|
return {
|
|
2702
|
-
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}
|
|
2996
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
2703
2997
|
};
|
|
2704
2998
|
}
|
|
2705
2999
|
});
|
|
2706
3000
|
// Tool: get_section_migration_plan
|
|
2707
|
-
server.tool("get_section_migration_plan", "
|
|
2708
|
-
theme_json: z.string().describe("Raw JSON content of the old theme.json"),
|
|
3001
|
+
server.tool("get_section_migration_plan", "Returns concrete CLI commands and prop conversions for one section. For each prop that references a customData type, you'll see a 'Decide: enum or component?' callout — log your decision in MIGRATION.md under `## Custom Data Decisions`. Pass `theme_json_path` for large themes.", {
|
|
3002
|
+
theme_json: z.string().optional().describe("Raw JSON content of the old theme.json. EITHER this OR theme_json_path is required (not both)."),
|
|
3003
|
+
theme_json_path: z.string().optional().describe("Absolute path to the old theme.json file on disk. Preferred for any real-world theme."),
|
|
2709
3004
|
section_name: z.string().describe("Old component name (e.g. 'Navbar', 'ProductGrid') or dir name, OR the new section ID (e.g. 'my-theme-navbar')"),
|
|
2710
3005
|
project_name: z.string().optional().describe("Target new project name (must match what was used in plan_migration). Default: 'my-theme'"),
|
|
2711
3006
|
old_source_dir: z.string().optional().describe("Absolute path to old src/ directory (used to output exact source file paths to read)"),
|
|
2712
|
-
}, async ({ theme_json, section_name, project_name, old_source_dir }) => {
|
|
3007
|
+
}, async ({ theme_json, theme_json_path, section_name, project_name, old_source_dir }) => {
|
|
2713
3008
|
try {
|
|
2714
|
-
const parsed =
|
|
3009
|
+
const parsed = resolveThemeJson(theme_json, theme_json_path);
|
|
2715
3010
|
const projectName = project_name || "my-theme";
|
|
2716
3011
|
const plan = generateSectionMigrationPlan(parsed, section_name, projectName, old_source_dir);
|
|
2717
3012
|
return { content: [{ type: "text", text: plan }] };
|
|
2718
3013
|
}
|
|
2719
3014
|
catch (err) {
|
|
2720
3015
|
return {
|
|
2721
|
-
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}
|
|
3016
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
2722
3017
|
};
|
|
2723
3018
|
}
|
|
2724
3019
|
});
|
|
3020
|
+
// =============================================================================
|
|
3021
|
+
// Editor-action tools — drive the connected editor over the dev server's WS
|
|
3022
|
+
// (port 5201) by shelling out to `ikas-component <cmd>`. The CLI is the
|
|
3023
|
+
// canonical implementation; these tools are thin advertisements for the LLM.
|
|
3024
|
+
// =============================================================================
|
|
3025
|
+
function resolveIkasComponentBinary(projectRoot) {
|
|
3026
|
+
if (!path.isAbsolute(projectRoot)) {
|
|
3027
|
+
throw new Error(`project_root must be absolute: ${projectRoot}`);
|
|
3028
|
+
}
|
|
3029
|
+
if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
|
|
3030
|
+
throw new Error(`project_root is not a directory: ${projectRoot}`);
|
|
3031
|
+
}
|
|
3032
|
+
const binDir = path.join(projectRoot, "node_modules", ".bin");
|
|
3033
|
+
const candidates = os.platform() === "win32"
|
|
3034
|
+
? ["ikas-component.cmd", "ikas-component.exe", "ikas-component"]
|
|
3035
|
+
: ["ikas-component"];
|
|
3036
|
+
for (const name of candidates) {
|
|
3037
|
+
const full = path.join(binDir, name);
|
|
3038
|
+
if (fs.existsSync(full))
|
|
3039
|
+
return full;
|
|
3040
|
+
}
|
|
3041
|
+
throw new Error(`ikas-component CLI not found at ${binDir}. Run \`npm install\` (or \`pnpm install\`) in ${projectRoot} first.`);
|
|
3042
|
+
}
|
|
3043
|
+
async function runIkasComponentCli(projectRoot, args) {
|
|
3044
|
+
const bin = resolveIkasComponentBinary(projectRoot);
|
|
3045
|
+
return new Promise((resolve) => {
|
|
3046
|
+
execFile(bin, args, { cwd: projectRoot, windowsHide: true }, (err, stdout, stderr) => {
|
|
3047
|
+
const exitCode = err && typeof err.code === "number"
|
|
3048
|
+
? err.code
|
|
3049
|
+
: err
|
|
3050
|
+
? 1
|
|
3051
|
+
: 0;
|
|
3052
|
+
resolve({ stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "", exitCode });
|
|
3053
|
+
});
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
function parseCliJson(stdout) {
|
|
3057
|
+
const trimmed = stdout.trim();
|
|
3058
|
+
if (!trimmed)
|
|
3059
|
+
return null;
|
|
3060
|
+
// CLI prints exactly one JSON object on stdout; if multiple lines appeared
|
|
3061
|
+
// (e.g., warnings on stderr leaked), take the last non-empty line.
|
|
3062
|
+
const last = trimmed.split("\n").map(l => l.trim()).filter(Boolean).pop();
|
|
3063
|
+
if (!last)
|
|
3064
|
+
return null;
|
|
3065
|
+
try {
|
|
3066
|
+
return JSON.parse(last);
|
|
3067
|
+
}
|
|
3068
|
+
catch {
|
|
3069
|
+
return null;
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
async function callEditorAction(projectRoot, args) {
|
|
3073
|
+
try {
|
|
3074
|
+
const { stdout, stderr, exitCode } = await runIkasComponentCli(projectRoot, args);
|
|
3075
|
+
const parsed = parseCliJson(stdout);
|
|
3076
|
+
if (parsed) {
|
|
3077
|
+
return { content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }] };
|
|
3078
|
+
}
|
|
3079
|
+
return {
|
|
3080
|
+
content: [
|
|
3081
|
+
{
|
|
3082
|
+
type: "text",
|
|
3083
|
+
text: `CLI exited with code ${exitCode} and produced no parseable JSON.\n` +
|
|
3084
|
+
`stdout:\n${stdout || "(empty)"}\nstderr:\n${stderr || "(empty)"}`,
|
|
3085
|
+
},
|
|
3086
|
+
],
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
catch (err) {
|
|
3090
|
+
return {
|
|
3091
|
+
content: [
|
|
3092
|
+
{
|
|
3093
|
+
type: "text",
|
|
3094
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
3095
|
+
},
|
|
3096
|
+
],
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
// Tool: list_editor_pages
|
|
3101
|
+
server.tool("list_editor_pages", "List pages in the connected editor's project. Returns id, name, pageType, slug for each page. Requires `ikas-component dev` to be running with the editor connected. Use the returned `id`s as the `pageId` argument to `add_section_to_page`.", {
|
|
3102
|
+
project_root: z.string().describe("Absolute path to the code-component project (where `node_modules/.bin/ikas-component` lives)."),
|
|
3103
|
+
port: z.number().optional().describe("Dev server WebSocket port (default 5201)."),
|
|
3104
|
+
}, async ({ project_root, port }) => {
|
|
3105
|
+
const args = ["list-pages", ...(port ? ["--port", String(port)] : [])];
|
|
3106
|
+
return callEditorAction(project_root, args);
|
|
3107
|
+
});
|
|
3108
|
+
// Tool: list_imported_sections
|
|
3109
|
+
server.tool("list_imported_sections", "List section-type code components already imported into the editor's project (theme.codeComponents, filtered to type=section). Use to confirm a component is ready to be placed on a page. The component must be built (`ikas-component build`/`dev`) and imported (`import_section`) before it can be added to a page. The returned `id` is the editor's id for the imported component — normally identical to the id in `ikas.config.json`, but it can differ if the dev component was deleted and re-scaffolded after a previous import. Always use the `id` from this tool, not from `ikas.config.json`, when calling `add_section_to_page`.", {
|
|
3110
|
+
project_root: z.string().describe("Absolute path to the code-component project."),
|
|
3111
|
+
port: z.number().optional().describe("Dev server WebSocket port (default 5201)."),
|
|
3112
|
+
}, async ({ project_root, port }) => {
|
|
3113
|
+
const args = ["list-imported", "--sections-only", ...(port ? ["--port", String(port)] : [])];
|
|
3114
|
+
return callEditorAction(project_root, args);
|
|
3115
|
+
});
|
|
3116
|
+
// Tool: import_section
|
|
3117
|
+
server.tool("import_section", "Import a built section-type code component into the editor's project. This copies the compiled JS/CSS/props into `theme.codeComponents` and auto-creates the wrapper section needed to place it on a page. Idempotent: re-running updates the existing entry in place. Required before `add_section_to_page`. The component must have been built (`ikas-component build`/`dev`).", {
|
|
3118
|
+
project_root: z.string().describe("Absolute path to the code-component project."),
|
|
3119
|
+
component_id: z.string().describe("Component id from `ikas.config.json` (strict — no name resolution)."),
|
|
3120
|
+
port: z.number().optional().describe("Dev server WebSocket port (default 5201)."),
|
|
3121
|
+
}, async ({ project_root, component_id, port }) => {
|
|
3122
|
+
const args = ["import", "--id", component_id, ...(port ? ["--port", String(port)] : [])];
|
|
3123
|
+
return callEditorAction(project_root, args);
|
|
3124
|
+
});
|
|
3125
|
+
// Tool: add_section_to_page
|
|
3126
|
+
server.tool("add_section_to_page", "Place an already-imported section-type code component on a page in the editor. Equivalent to right-clicking the section in the dev-components panel and choosing \"Add to Page\". Errors if the component is not imported, is not section-type, or the page id is unknown. Use `list_editor_pages` to discover page ids and `list_imported_sections` to discover component ids.", {
|
|
3127
|
+
project_root: z.string().describe("Absolute path to the code-component project."),
|
|
3128
|
+
component_id: z.string().describe("Imported code component id from `list_imported_sections` — NOT the id in `ikas.config.json` (the two are usually the same but can diverge after a rescaffold)."),
|
|
3129
|
+
page_id: z.string().describe("Target page id (from `list_editor_pages`)."),
|
|
3130
|
+
index: z.number().int().nonnegative().optional().describe("Zero-based insertion index in the page; appends when omitted."),
|
|
3131
|
+
port: z.number().optional().describe("Dev server WebSocket port (default 5201)."),
|
|
3132
|
+
}, async ({ project_root, component_id, page_id, index, port }) => {
|
|
3133
|
+
const args = [
|
|
3134
|
+
"add-to-page",
|
|
3135
|
+
"--component-id",
|
|
3136
|
+
component_id,
|
|
3137
|
+
"--page-id",
|
|
3138
|
+
page_id,
|
|
3139
|
+
...(typeof index === "number" ? ["--index", String(index)] : []),
|
|
3140
|
+
...(port ? ["--port", String(port)] : []),
|
|
3141
|
+
];
|
|
3142
|
+
return callEditorAction(project_root, args);
|
|
3143
|
+
});
|
|
2725
3144
|
// --- Start server ---
|
|
2726
3145
|
async function main() {
|
|
2727
3146
|
const transport = new StdioServerTransport();
|