fwinit 1.6.2 → 1.7.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 (2) hide show
  1. package/dist/index.js +3414 -56
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@ import { Command } from "commander";
5
5
  import ora from "ora";
6
6
  import chalk from "chalk";
7
7
  import { readFileSync } from "fs";
8
- import { mkdir as mkdir3, cp as cp3, readFile as readFile3 } from "fs/promises";
9
- import { join as join3, basename as basename2 } from "path";
8
+ import { mkdir as mkdir3, cp as cp3, readFile as readFile5 } from "fs/promises";
9
+ import { join as join6, basename as basename2 } from "path";
10
10
 
11
11
  // src/templates.ts
12
12
  var TEMPLATES = [
@@ -679,33 +679,33 @@ async function exists(path) {
679
679
  }
680
680
  async function listMarkdown(dir) {
681
681
  try {
682
- const { readdir: readdir3 } = await import("fs/promises");
683
- return (await readdir3(dir)).filter((f) => f.endsWith(".md"));
682
+ const { readdir: readdir5 } = await import("fs/promises");
683
+ return (await readdir5(dir)).filter((f) => f.endsWith(".md"));
684
684
  } catch {
685
685
  return [];
686
686
  }
687
687
  }
688
688
  async function planAgentBundle(repoRoot, bundle = AGENT_BUNDLE) {
689
- const { join: join4 } = await import("path");
689
+ const { join: join7 } = await import("path");
690
690
  const installs = [];
691
691
  const skillDirs = [];
692
692
  const missingRequired = [];
693
693
  for (const entry of bundle) {
694
- const src = join4(repoRoot, "skills", entry.dir);
694
+ const src = join7(repoRoot, "skills", entry.dir);
695
695
  if (entry.kind === "library") {
696
696
  if (!await exists(src)) {
697
697
  if (entry.required) missingRequired.push(entry.dir);
698
698
  continue;
699
699
  }
700
700
  installs.push({ from: src, to: `skills/${entry.dir}`, recursive: true });
701
- const cmds = join4(src, "commands");
701
+ const cmds = join7(src, "commands");
702
702
  for (const file of await listMarkdown(cmds)) {
703
- installs.push({ from: join4(cmds, file), to: `commands/${file}` });
703
+ installs.push({ from: join7(cmds, file), to: `commands/${file}` });
704
704
  }
705
705
  continue;
706
706
  }
707
- const skill = join4(src, "SKILL.md");
708
- const command = join4(src, "command.md");
707
+ const skill = join7(src, "SKILL.md");
708
+ const command = join7(src, "command.md");
709
709
  if (!await exists(skill)) {
710
710
  if (entry.required) missingRequired.push(entry.dir);
711
711
  continue;
@@ -718,7 +718,7 @@ async function planAgentBundle(repoRoot, bundle = AGENT_BUNDLE) {
718
718
  missingRequired.push(`${entry.dir}/command.md`);
719
719
  }
720
720
  for (const extra of entry.extras ?? []) {
721
- const from = join4(src, extra);
721
+ const from = join7(src, extra);
722
722
  if (!await exists(from)) continue;
723
723
  installs.push({
724
724
  from,
@@ -730,6 +730,3306 @@ async function planAgentBundle(repoRoot, bundle = AGENT_BUNDLE) {
730
730
  return { installs, skillDirs, commandDirs: ["commands"], missingRequired };
731
731
  }
732
732
 
733
+ // src/studio/server.ts
734
+ import { createServer } from "http";
735
+ import { join as join4, resolve as resolve3 } from "path";
736
+
737
+ // src/studio/specs.ts
738
+ import { readdir as readdir2, readFile as readFile2, realpath, stat as stat2 } from "fs/promises";
739
+ import { extname as extname2, join as join2, relative as relative2, resolve, sep as sep2 } from "path";
740
+
741
+ // src/studio/markdown.ts
742
+ var ESCAPES = {
743
+ "&": "&",
744
+ "<": "&lt;",
745
+ ">": "&gt;",
746
+ '"': "&quot;",
747
+ "'": "&#39;"
748
+ };
749
+ function esc(text) {
750
+ return text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
751
+ }
752
+ function slugify(text) {
753
+ const slug = text.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/[\s-]+/g, "-").replace(/^-+|-+$/g, "");
754
+ return slug === "" ? "section" : slug;
755
+ }
756
+ function plainText(source) {
757
+ return source.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/\*\*\*|\*\*|\*|___|__|_|~~/g, "").trim();
758
+ }
759
+ function matchLink(source, start) {
760
+ const close = source.indexOf("]", start + 1);
761
+ if (close === -1) return null;
762
+ if (source[close + 1] !== "(") return null;
763
+ const destination = matchDestination(source, close + 1);
764
+ if (destination === null) return null;
765
+ return {
766
+ text: source.slice(start + 1, close),
767
+ target: destination.target,
768
+ end: destination.end
769
+ };
770
+ }
771
+ function matchDestination(source, openParen) {
772
+ let depth = 1;
773
+ let index = openParen + 1;
774
+ while (index < source.length) {
775
+ const char = source[index];
776
+ if (char === "(") depth += 1;
777
+ else if (char === ")") {
778
+ depth -= 1;
779
+ if (depth === 0) {
780
+ return { target: source.slice(openParen + 1, index), end: index + 1 };
781
+ }
782
+ }
783
+ index += 1;
784
+ }
785
+ return null;
786
+ }
787
+ var SAFE_PROTOCOL = /^(?:https?|mailto):/i;
788
+ var ANY_PROTOCOL = /^[a-z][a-z0-9+.-]*:/i;
789
+ var SPEC_PATH = /\.(?:md|markdown)$/i;
790
+ function normalizeSpecPath(target) {
791
+ const path = target.split("#")[0].split("?")[0].replace(/\\/g, "/");
792
+ const segments = [];
793
+ for (const segment of path.split("/")) {
794
+ if (segment === "" || segment === ".") continue;
795
+ segments.push(segment);
796
+ }
797
+ return segments.join("/");
798
+ }
799
+ function renderLink(text, rawTarget) {
800
+ const target = rawTarget.trim();
801
+ const inner = renderInline(text);
802
+ if (SAFE_PROTOCOL.test(target)) {
803
+ return `<a href="${esc(target)}" rel="noreferrer noopener">${inner}</a>`;
804
+ }
805
+ if (ANY_PROTOCOL.test(target)) return inner;
806
+ if (target.startsWith("/") || target.startsWith("\\")) return inner;
807
+ const specPath = normalizeSpecPath(target);
808
+ if (SPEC_PATH.test(specPath)) {
809
+ return `<a href="#" data-spec-link="${esc(specPath)}">${inner}</a>`;
810
+ }
811
+ return inner;
812
+ }
813
+ var WORD_CHAR = /[\p{L}\p{N}]/u;
814
+ function findEmphasisClose(source, char, size, from) {
815
+ let index = from;
816
+ while (index < source.length) {
817
+ if (source[index] !== char) {
818
+ index += 1;
819
+ continue;
820
+ }
821
+ let length = 0;
822
+ while (source[index + length] === char) length += 1;
823
+ if (length >= size) {
824
+ if (char === "_" && WORD_CHAR.test(source[index + length] ?? "")) {
825
+ index += length;
826
+ continue;
827
+ }
828
+ return { start: index, length };
829
+ }
830
+ index += length;
831
+ }
832
+ return null;
833
+ }
834
+ function matchEmphasis(source, start) {
835
+ const char = source[start];
836
+ if (char === "~") {
837
+ if (source[start + 1] !== "~") return null;
838
+ const close = source.indexOf("~~", start + 2);
839
+ if (close <= start + 2) return null;
840
+ return {
841
+ html: `<del>${renderInline(source.slice(start + 2, close))}</del>`,
842
+ end: close + 2
843
+ };
844
+ }
845
+ let run = 0;
846
+ while (source[start + run] === char && run < 3) run += 1;
847
+ if (char === "_" && start > 0 && WORD_CHAR.test(source[start - 1])) return null;
848
+ for (let size = run; size >= 1; size -= 1) {
849
+ const close = findEmphasisClose(source, char, size, start + size);
850
+ if (close === null) continue;
851
+ if (close.start === start + size) continue;
852
+ const extra = close.length - size;
853
+ const content = source.slice(start + size, close.start + extra);
854
+ const inner = renderInline(content);
855
+ if (size === 3) {
856
+ return { html: `<strong><em>${inner}</em></strong>`, end: close.start + close.length };
857
+ }
858
+ if (size === 2) {
859
+ return { html: `<strong>${inner}</strong>`, end: close.start + close.length };
860
+ }
861
+ return { html: `<em>${inner}</em>`, end: close.start + close.length };
862
+ }
863
+ return null;
864
+ }
865
+ function renderInline(source) {
866
+ let html = "";
867
+ let index = 0;
868
+ while (index < source.length) {
869
+ const char = source[index];
870
+ if (char === "`") {
871
+ const close = source.indexOf("`", index + 1);
872
+ if (close > index + 1) {
873
+ html += `<code>${esc(source.slice(index + 1, close))}</code>`;
874
+ index = close + 1;
875
+ continue;
876
+ }
877
+ }
878
+ if (char === "!" && source[index + 1] === "[") {
879
+ const image = matchLink(source, index + 1);
880
+ if (image !== null) {
881
+ html += `<span class="md-img">${renderInline(image.text)}</span>`;
882
+ index = image.end;
883
+ continue;
884
+ }
885
+ }
886
+ if (char === "[") {
887
+ const link = matchLink(source, index);
888
+ if (link !== null) {
889
+ html += renderLink(link.text, link.target);
890
+ index = link.end;
891
+ continue;
892
+ }
893
+ }
894
+ if (char === "*" || char === "_" || char === "~") {
895
+ const emphasis = matchEmphasis(source, index);
896
+ if (emphasis !== null) {
897
+ html += emphasis.html;
898
+ index = emphasis.end;
899
+ continue;
900
+ }
901
+ }
902
+ html += esc(char);
903
+ index += 1;
904
+ }
905
+ return html;
906
+ }
907
+ var LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
908
+ var FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*([^`]*)$/;
909
+ var HEADING = /^(#{1,6})\s+(.*)$/;
910
+ var RULE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
911
+ var QUOTE = /^\s{0,3}>/;
912
+ function tableCells(line) {
913
+ let trimmed = line.trim();
914
+ if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
915
+ if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
916
+ return trimmed.split("|").map((cell) => cell.trim());
917
+ }
918
+ function isTableSeparator(line) {
919
+ if (!line.includes("|")) return false;
920
+ const cells = tableCells(line);
921
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
922
+ }
923
+ function isBlockStart(lines, index) {
924
+ const line = lines[index];
925
+ if (line.trim() === "") return true;
926
+ if (FENCE.test(line)) return true;
927
+ if (HEADING.test(line)) return true;
928
+ if (RULE.test(line)) return true;
929
+ if (QUOTE.test(line)) return true;
930
+ if (LIST_ITEM.test(line)) return true;
931
+ if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
932
+ return true;
933
+ }
934
+ return false;
935
+ }
936
+ function renderMarkdown(markdown) {
937
+ const headings = [];
938
+ const usedIds = /* @__PURE__ */ new Map();
939
+ const uniqueId = (base) => {
940
+ const seen = usedIds.get(base) ?? 0;
941
+ usedIds.set(base, seen + 1);
942
+ return seen === 0 ? base : `${base}-${seen + 1}`;
943
+ };
944
+ const renderItem = (item) => {
945
+ const task = /^\[([ xX])\]\s+(.*)$/.exec(item.text);
946
+ if (task !== null) {
947
+ const checked = task[1].toLowerCase() === "x";
948
+ const box = `<input type="checkbox" disabled${checked ? " checked" : ""}>`;
949
+ return { html: `${box} ${renderInline(task[2])}`, task: true };
950
+ }
951
+ return { html: renderInline(item.text), task: false };
952
+ };
953
+ const renderItems = (items, ordered) => {
954
+ const tag = ordered ? "ol" : "ul";
955
+ const parts = items.map((item) => {
956
+ const { html, task } = renderItem(item);
957
+ const cls = task ? ' class="task"' : "";
958
+ return `<li${cls}>${html}</li>`;
959
+ });
960
+ return `<${tag}>${parts.join("")}</${tag}>`;
961
+ };
962
+ const renderBlocks = (lines) => {
963
+ const out = [];
964
+ let index = 0;
965
+ while (index < lines.length) {
966
+ const line = lines[index];
967
+ if (line.trim() === "") {
968
+ index += 1;
969
+ continue;
970
+ }
971
+ const fence = FENCE.exec(line);
972
+ if (fence !== null) {
973
+ const marker = fence[1];
974
+ const fenceChar = marker[0];
975
+ const lang = fence[2].trim().split(/\s+/)[0] ?? "";
976
+ const body = [];
977
+ index += 1;
978
+ while (index < lines.length) {
979
+ const candidate = lines[index];
980
+ const trimmed = candidate.trim();
981
+ const closes = trimmed.length >= marker.length && trimmed.split("").every((char) => char === fenceChar);
982
+ if (closes) {
983
+ index += 1;
984
+ break;
985
+ }
986
+ body.push(candidate);
987
+ index += 1;
988
+ }
989
+ const cls = lang === "" ? "" : ` class="lang-${esc(lang)}"`;
990
+ out.push(`<pre><code${cls}>${esc(body.join("\n"))}</code></pre>`);
991
+ continue;
992
+ }
993
+ const heading = HEADING.exec(line);
994
+ if (heading !== null) {
995
+ const level = heading[1].length;
996
+ const raw = heading[2].replace(/\s+#+\s*$/, "").trim();
997
+ const text = plainText(raw);
998
+ const id = uniqueId(slugify(text));
999
+ headings.push({ level, text, id });
1000
+ out.push(`<h${level} id="${esc(id)}">${renderInline(raw)}</h${level}>`);
1001
+ index += 1;
1002
+ continue;
1003
+ }
1004
+ if (RULE.test(line)) {
1005
+ out.push("<hr>");
1006
+ index += 1;
1007
+ continue;
1008
+ }
1009
+ if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
1010
+ const header = tableCells(line);
1011
+ const rows = [];
1012
+ index += 2;
1013
+ while (index < lines.length && lines[index].trim() !== "" && lines[index].includes("|")) {
1014
+ rows.push(tableCells(lines[index]));
1015
+ index += 1;
1016
+ }
1017
+ const head = header.map((cell) => `<th>${renderInline(cell)}</th>`).join("");
1018
+ const body = rows.map(
1019
+ (row) => `<tr>${row.map((cell) => `<td>${renderInline(cell)}</td>`).join("")}</tr>`
1020
+ ).join("");
1021
+ out.push(
1022
+ `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
1023
+ );
1024
+ continue;
1025
+ }
1026
+ if (QUOTE.test(line)) {
1027
+ const quoted = [];
1028
+ while (index < lines.length && QUOTE.test(lines[index])) {
1029
+ quoted.push(lines[index].replace(/^\s{0,3}>\s?/, ""));
1030
+ index += 1;
1031
+ }
1032
+ out.push(`<blockquote>${renderBlocks(quoted)}</blockquote>`);
1033
+ continue;
1034
+ }
1035
+ if (LIST_ITEM.test(line)) {
1036
+ const items = [];
1037
+ let baseIndent = -1;
1038
+ while (index < lines.length) {
1039
+ const match = LIST_ITEM.exec(lines[index]);
1040
+ if (match === null) break;
1041
+ const indent = match[1].length;
1042
+ if (baseIndent === -1) baseIndent = indent;
1043
+ items.push({
1044
+ indent,
1045
+ ordered: /\d/.test(match[2]),
1046
+ text: match[3]
1047
+ });
1048
+ index += 1;
1049
+ }
1050
+ const top = [];
1051
+ for (const item of items) {
1052
+ if (item.indent > baseIndent && top.length > 0) {
1053
+ top[top.length - 1].children.push(item);
1054
+ } else {
1055
+ top.push({ item, children: [] });
1056
+ }
1057
+ }
1058
+ const ordered = top[0].item.ordered;
1059
+ const lis = top.map(({ item, children }) => {
1060
+ const { html, task } = renderItem(item);
1061
+ const cls = task ? ' class="task"' : "";
1062
+ const nested = children.length > 0 ? renderItems(children, children[0].ordered) : "";
1063
+ return `<li${cls}>${html}${nested}</li>`;
1064
+ });
1065
+ out.push(`<${ordered ? "ol" : "ul"}>${lis.join("")}</${ordered ? "ol" : "ul"}>`);
1066
+ continue;
1067
+ }
1068
+ const paragraph = [];
1069
+ const start = index;
1070
+ while (index < lines.length) {
1071
+ const current = lines[index];
1072
+ if (current.trim() === "") break;
1073
+ if (index > start && isBlockStart(lines, index)) break;
1074
+ paragraph.push(current.trim());
1075
+ index += 1;
1076
+ }
1077
+ out.push(`<p>${renderInline(paragraph.join("\n"))}</p>`);
1078
+ }
1079
+ return out.join("");
1080
+ };
1081
+ return { html: renderBlocks(markdown.replace(/\r\n?/g, "\n").split("\n")), headings };
1082
+ }
1083
+
1084
+ // src/studio/specs.ts
1085
+ var SKIPPED_NAMES = /* @__PURE__ */ new Set([".git", "node_modules"]);
1086
+ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown", ".txt"]);
1087
+ var SpecPathError = class extends Error {
1088
+ code;
1089
+ path;
1090
+ constructor(code, path, message) {
1091
+ super(message);
1092
+ this.name = "SpecPathError";
1093
+ this.code = code;
1094
+ this.path = path;
1095
+ }
1096
+ };
1097
+ function isInside(root, candidate) {
1098
+ if (candidate === root) return true;
1099
+ const prefix = root.endsWith(sep2) ? root : `${root}${sep2}`;
1100
+ return candidate.startsWith(prefix);
1101
+ }
1102
+ async function realpathOrNull(path) {
1103
+ try {
1104
+ return await realpath(path);
1105
+ } catch {
1106
+ return null;
1107
+ }
1108
+ }
1109
+ function compareNodes(a, b) {
1110
+ const left = a.name.toLowerCase();
1111
+ const right = b.name.toLowerCase();
1112
+ const byName = left.localeCompare(right);
1113
+ return byName !== 0 ? byName : a.name.localeCompare(b.name);
1114
+ }
1115
+ async function scanDirectory(absoluteDir, relativeDir, totals) {
1116
+ let entries;
1117
+ try {
1118
+ entries = await readdir2(absoluteDir, { withFileTypes: true });
1119
+ } catch {
1120
+ return [];
1121
+ }
1122
+ const dirs = [];
1123
+ const files = [];
1124
+ for (const entry of entries) {
1125
+ if (entry.isSymbolicLink()) continue;
1126
+ if (entry.name.startsWith(".") || SKIPPED_NAMES.has(entry.name)) continue;
1127
+ const childPath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
1128
+ if (entry.isDirectory()) {
1129
+ const children = await scanDirectory(join2(absoluteDir, entry.name), childPath, totals);
1130
+ totals.dirs += 1;
1131
+ dirs.push({ name: entry.name, path: childPath, type: "dir", children });
1132
+ continue;
1133
+ }
1134
+ if (!entry.isFile()) continue;
1135
+ let info;
1136
+ try {
1137
+ info = await stat2(join2(absoluteDir, entry.name));
1138
+ } catch {
1139
+ continue;
1140
+ }
1141
+ totals.files += 1;
1142
+ totals.bytes += info.size;
1143
+ files.push({
1144
+ name: entry.name,
1145
+ path: childPath,
1146
+ type: "file",
1147
+ bytes: info.size,
1148
+ modified: info.mtime.toISOString()
1149
+ });
1150
+ }
1151
+ dirs.sort(compareNodes);
1152
+ files.sort(compareNodes);
1153
+ return [...dirs, ...files];
1154
+ }
1155
+ async function buildSpecsTree(projectRoot) {
1156
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1157
+ const specsRoot = join2(projectRoot, "specs");
1158
+ const empty = {
1159
+ found: false,
1160
+ root: null,
1161
+ projectRoot,
1162
+ sections: [],
1163
+ tree: [],
1164
+ totals: { files: 0, dirs: 0, bytes: 0 },
1165
+ generatedAt
1166
+ };
1167
+ let rootInfo;
1168
+ try {
1169
+ rootInfo = await stat2(specsRoot);
1170
+ } catch {
1171
+ return empty;
1172
+ }
1173
+ if (!rootInfo.isDirectory()) return empty;
1174
+ const totals = { files: 0, dirs: 0, bytes: 0 };
1175
+ const tree = await scanDirectory(specsRoot, "", totals);
1176
+ const sections = tree.filter((node) => node.type === "dir").map((node) => node.name);
1177
+ return {
1178
+ found: true,
1179
+ root: specsRoot,
1180
+ projectRoot,
1181
+ sections,
1182
+ tree,
1183
+ totals,
1184
+ generatedAt
1185
+ };
1186
+ }
1187
+ function frontmatterEndIndex(lines) {
1188
+ if (lines[0]?.trim() !== "---") return -1;
1189
+ return lines.findIndex(
1190
+ (line, index) => index > 0 && (line.trim() === "---" || line.trim() === "...")
1191
+ );
1192
+ }
1193
+ function stripFrontmatter(markdown) {
1194
+ const lines = markdown.split(/\r?\n/);
1195
+ const end = frontmatterEndIndex(lines);
1196
+ if (end === -1) return markdown;
1197
+ return lines.slice(end + 1).join("\n");
1198
+ }
1199
+ function collectWarnings(markdown) {
1200
+ const warnings = [];
1201
+ const lines = markdown.split(/\r?\n/);
1202
+ if (lines[0]?.trim() === "---" && frontmatterEndIndex(lines) === -1) {
1203
+ warnings.push("El bloque de frontmatter no se cierra (falta `---`).");
1204
+ }
1205
+ return warnings;
1206
+ }
1207
+ function assertRelativePath(requested) {
1208
+ if (requested.trim() === "") {
1209
+ throw new SpecPathError("invalid", requested, "La ruta de la spec est\xE1 vac\xEDa.");
1210
+ }
1211
+ if (requested.includes("\0")) {
1212
+ throw new SpecPathError("invalid", requested, "La ruta de la spec contiene un NUL.");
1213
+ }
1214
+ if (requested.startsWith("/") || requested.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(requested)) {
1215
+ throw new SpecPathError(
1216
+ "invalid",
1217
+ requested,
1218
+ "La ruta de la spec debe ser relativa a specs/."
1219
+ );
1220
+ }
1221
+ return requested.split(/[\\/]+/).filter((segment) => segment !== "" && segment !== ".");
1222
+ }
1223
+ function resolveSpecLink(baseDir, target) {
1224
+ const stack = baseDir.filter((segment) => segment !== "");
1225
+ for (const segment of target.split(/[\\/]+/)) {
1226
+ if (segment === "" || segment === ".") continue;
1227
+ if (segment === "..") {
1228
+ stack.pop();
1229
+ continue;
1230
+ }
1231
+ stack.push(segment);
1232
+ }
1233
+ return stack.join("/");
1234
+ }
1235
+ function normalizeSpecLinks(html, basePath) {
1236
+ const baseDir = basePath.split("/").slice(0, -1);
1237
+ return html.replace(/data-spec-link="([^"]*)"/g, (_match, target) => {
1238
+ return `data-spec-link="${resolveSpecLink(baseDir, target)}"`;
1239
+ });
1240
+ }
1241
+ async function readSpecFile(roots, relativePath) {
1242
+ const segments = assertRelativePath(relativePath);
1243
+ const logicalRoot = resolve(roots.specsRoot);
1244
+ const logical = resolve(logicalRoot, relativePath);
1245
+ if (!isInside(logicalRoot, logical)) {
1246
+ throw new SpecPathError("outside", relativePath, "La spec pedida est\xE1 fuera de specs/.");
1247
+ }
1248
+ if (segments.includes("..")) {
1249
+ throw new SpecPathError(
1250
+ "invalid",
1251
+ relativePath,
1252
+ "La ruta de la spec no puede contener segmentos '..'."
1253
+ );
1254
+ }
1255
+ const realRoot = await realpathOrNull(logicalRoot);
1256
+ if (realRoot === null) {
1257
+ throw new SpecPathError("missing", relativePath, "El proyecto no tiene carpeta specs/.");
1258
+ }
1259
+ const real = await realpathOrNull(logical);
1260
+ if (real === null) {
1261
+ throw new SpecPathError("missing", relativePath, `No existe la spec ${relativePath}.`);
1262
+ }
1263
+ if (!isInside(realRoot, real)) {
1264
+ throw new SpecPathError("outside", relativePath, "La spec resuelve fuera de specs/.");
1265
+ }
1266
+ const info = await stat2(real).catch(() => null);
1267
+ if (info === null) {
1268
+ throw new SpecPathError("missing", relativePath, `No existe la spec ${relativePath}.`);
1269
+ }
1270
+ if (!info.isFile()) {
1271
+ throw new SpecPathError("unsupported", relativePath, "La spec no es un archivo regular.");
1272
+ }
1273
+ const extension = extname2(real).toLowerCase();
1274
+ if (!SUPPORTED_EXTENSIONS.has(extension)) {
1275
+ throw new SpecPathError(
1276
+ "unsupported",
1277
+ relativePath,
1278
+ `Extensi\xF3n no soportada (${extension || "sin extensi\xF3n"}); us\xE1 .md, .markdown o .txt.`
1279
+ );
1280
+ }
1281
+ const markdown = await readFile2(real, "utf8").catch(() => null);
1282
+ if (markdown === null) {
1283
+ throw new SpecPathError("missing", relativePath, `No se pudo leer ${relativePath}.`);
1284
+ }
1285
+ const { html: rawHtml, headings } = renderMarkdown(stripFrontmatter(markdown));
1286
+ const level1 = headings.find((heading) => heading.level === 1);
1287
+ const posixPath = relative2(logicalRoot, logical).split(sep2).join("/");
1288
+ const html = normalizeSpecLinks(rawHtml, posixPath);
1289
+ return {
1290
+ path: posixPath,
1291
+ absolutePath: logical,
1292
+ title: level1?.text || posixPath.split("/").pop() || posixPath,
1293
+ markdown,
1294
+ html,
1295
+ headings,
1296
+ bytes: info.size,
1297
+ modified: info.mtime.toISOString(),
1298
+ warnings: collectWarnings(markdown)
1299
+ };
1300
+ }
1301
+
1302
+ // src/studio/skills.ts
1303
+ import { readdir as readdir3, readFile as readFile3, stat as stat3 } from "fs/promises";
1304
+ import { homedir } from "os";
1305
+ import { join as join3, resolve as resolve2 } from "path";
1306
+
1307
+ // src/studio/frontmatter.ts
1308
+ var DELIMITER = "---";
1309
+ var KEY_VALUE = /^([A-Za-z0-9_.-]+)[ \t]*:[ \t]*(.*)$/;
1310
+ var MAX_LINE_IN_WARNING = 80;
1311
+ function splitLines(source) {
1312
+ return source.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1313
+ }
1314
+ function stripQuotes(value) {
1315
+ if (value.length < 2) return value;
1316
+ const first = value[0];
1317
+ const last = value[value.length - 1];
1318
+ if ((first === '"' || first === "'") && first === last) {
1319
+ return value.slice(1, -1);
1320
+ }
1321
+ return value;
1322
+ }
1323
+ function malformed(reason, body) {
1324
+ return { data: {}, warnings: [`malformed frontmatter: ${reason}`], body };
1325
+ }
1326
+ function parseFrontmatter(source) {
1327
+ const lines = splitLines(source);
1328
+ if ((lines[0] ?? "").trimEnd() !== DELIMITER) {
1329
+ return { data: {}, warnings: [], body: source };
1330
+ }
1331
+ let closing = -1;
1332
+ for (let index = 1; index < lines.length; index++) {
1333
+ if (lines[index].trimEnd() === DELIMITER) {
1334
+ closing = index;
1335
+ break;
1336
+ }
1337
+ }
1338
+ if (closing === -1) {
1339
+ return {
1340
+ data: {},
1341
+ warnings: ["frontmatter: block is unterminated"],
1342
+ body: ""
1343
+ };
1344
+ }
1345
+ const data = {};
1346
+ const body = lines.slice(closing + 1).join("\n");
1347
+ let parent = null;
1348
+ for (const raw of lines.slice(1, closing)) {
1349
+ if (!raw.trim()) continue;
1350
+ if (raw.trimStart().startsWith("#")) continue;
1351
+ const indented = /^[ \t]/.test(raw);
1352
+ const match = KEY_VALUE.exec(raw.trim());
1353
+ if (!match) {
1354
+ return malformed(
1355
+ `line is not "key: value": ${raw.trim().slice(0, MAX_LINE_IN_WARNING)}`,
1356
+ body
1357
+ );
1358
+ }
1359
+ const key = match[1];
1360
+ const rawValue = match[2].trim();
1361
+ const value = stripQuotes(rawValue);
1362
+ if (indented) {
1363
+ if (parent === null) {
1364
+ return malformed(`indented key without a parent block: ${key}`, body);
1365
+ }
1366
+ if (rawValue === "") {
1367
+ return malformed(`nested key has no value: ${parent}.${key}`, body);
1368
+ }
1369
+ data[`${parent}.${key}`] = value;
1370
+ continue;
1371
+ }
1372
+ if (rawValue === "") {
1373
+ parent = key;
1374
+ continue;
1375
+ }
1376
+ parent = null;
1377
+ data[key] = value;
1378
+ }
1379
+ return { data, warnings: [], body };
1380
+ }
1381
+
1382
+ // src/studio/skills.ts
1383
+ var ENTRY_FILE = "skill.md";
1384
+ var COMMAND_FILE = "command.md";
1385
+ function isMissing(error) {
1386
+ const code = error?.code;
1387
+ return code === "ENOENT" || code === "ENOTDIR";
1388
+ }
1389
+ function resolveSkillTargets(options) {
1390
+ const home = resolve2(options.home ?? homedir());
1391
+ const projectRoot = resolve2(options.projectRoot);
1392
+ const gentleShellRoot = process.env.GENTLE_SHELL_HOME?.trim() || join3(home, ".gentle-shell", "agent");
1393
+ return [
1394
+ {
1395
+ id: "opencode",
1396
+ label: "opencode",
1397
+ globalDir: join3(home, ".config", "opencode", "skills"),
1398
+ projectDir: join3(projectRoot, ".opencode", "skills")
1399
+ },
1400
+ {
1401
+ id: "pi",
1402
+ label: "pi",
1403
+ globalDir: join3(home, ".pi", "agent", "skills"),
1404
+ projectDir: join3(projectRoot, ".pi", "skills")
1405
+ },
1406
+ {
1407
+ id: "gentle-shell",
1408
+ label: "Gentle Shell",
1409
+ globalDir: join3(gentleShellRoot, "skills"),
1410
+ projectDir: join3(projectRoot, ".gentle-shell", "skills")
1411
+ }
1412
+ ];
1413
+ }
1414
+ function compareNames(a, b) {
1415
+ const left = a.toLowerCase();
1416
+ const right = b.toLowerCase();
1417
+ if (left < right) return -1;
1418
+ if (left > right) return 1;
1419
+ if (a < b) return -1;
1420
+ if (a > b) return 1;
1421
+ return 0;
1422
+ }
1423
+ function firstProseLine(body) {
1424
+ let inFence = false;
1425
+ for (const raw of body.split(/\r?\n/)) {
1426
+ const line = raw.trim();
1427
+ if (!line) continue;
1428
+ if (/^(?:```|~~~)/.test(line)) {
1429
+ inFence = !inFence;
1430
+ continue;
1431
+ }
1432
+ if (inFence) continue;
1433
+ if (line.startsWith("#")) continue;
1434
+ if (line === "---") continue;
1435
+ return line;
1436
+ }
1437
+ return "";
1438
+ }
1439
+ async function measureDirectory(dir) {
1440
+ let files = 0;
1441
+ let bytes = 0;
1442
+ let entries;
1443
+ try {
1444
+ entries = await readdir3(dir, { withFileTypes: true });
1445
+ } catch {
1446
+ return { files, bytes };
1447
+ }
1448
+ for (const entry of entries) {
1449
+ if (entry.isSymbolicLink()) continue;
1450
+ const path = join3(dir, entry.name);
1451
+ if (entry.isDirectory()) {
1452
+ const nested = await measureDirectory(path);
1453
+ files += nested.files;
1454
+ bytes += nested.bytes;
1455
+ continue;
1456
+ }
1457
+ if (!entry.isFile()) continue;
1458
+ try {
1459
+ const info = await stat3(path);
1460
+ files += 1;
1461
+ bytes += info.size;
1462
+ } catch {
1463
+ }
1464
+ }
1465
+ return { files, bytes };
1466
+ }
1467
+ async function readSkill(dir, name, target, scope, warnings) {
1468
+ let entries;
1469
+ try {
1470
+ entries = await readdir3(dir, { withFileTypes: true });
1471
+ } catch {
1472
+ warnings.push(`cannot read skill directory: ${dir}`);
1473
+ return null;
1474
+ }
1475
+ const entryFile = entries.find(
1476
+ (entry) => entry.isFile() && entry.name.toLowerCase() === ENTRY_FILE
1477
+ );
1478
+ if (!entryFile) return null;
1479
+ const entryPath = join3(dir, entryFile.name);
1480
+ let source;
1481
+ let entryInfo;
1482
+ try {
1483
+ source = await readFile3(entryPath, "utf-8");
1484
+ entryInfo = await stat3(entryPath);
1485
+ } catch {
1486
+ warnings.push(`cannot read skill entry file: ${entryPath}`);
1487
+ return null;
1488
+ }
1489
+ const parsed = parseFrontmatter(source);
1490
+ const { files, bytes } = await measureDirectory(dir);
1491
+ const version = parsed.data["metadata.version"]?.trim();
1492
+ const author = parsed.data["metadata.author"]?.trim();
1493
+ const license = parsed.data["license"]?.trim();
1494
+ const description = parsed.data["description"]?.trim() || firstProseLine(parsed.body);
1495
+ return {
1496
+ name,
1497
+ description,
1498
+ version: version || void 0,
1499
+ license: license || void 0,
1500
+ author: author || void 0,
1501
+ dir,
1502
+ path: entryPath,
1503
+ target,
1504
+ scope,
1505
+ files,
1506
+ bytes,
1507
+ modified: entryInfo.mtime.toISOString(),
1508
+ hasCommand: entries.some(
1509
+ (entry) => entry.isFile() && entry.name.toLowerCase() === COMMAND_FILE
1510
+ ),
1511
+ warnings: parsed.warnings
1512
+ };
1513
+ }
1514
+ async function scanSkillsDir(dir, target, scope, warnings) {
1515
+ let info;
1516
+ try {
1517
+ info = await stat3(dir);
1518
+ } catch (error) {
1519
+ if (isMissing(error)) return { exists: false, skills: [] };
1520
+ warnings.push(`cannot read skills directory: ${dir}`);
1521
+ return { exists: false, skills: [] };
1522
+ }
1523
+ if (!info.isDirectory()) return { exists: false, skills: [] };
1524
+ let entries;
1525
+ try {
1526
+ entries = await readdir3(dir, { withFileTypes: true });
1527
+ } catch {
1528
+ warnings.push(`cannot list skills directory: ${dir}`);
1529
+ return { exists: true, skills: [] };
1530
+ }
1531
+ const skills = [];
1532
+ for (const entry of entries) {
1533
+ if (entry.isSymbolicLink()) {
1534
+ warnings.push(`skipped symlinked skill directory: ${join3(dir, entry.name)}`);
1535
+ continue;
1536
+ }
1537
+ if (!entry.isDirectory()) continue;
1538
+ const skill = await readSkill(
1539
+ join3(dir, entry.name),
1540
+ entry.name,
1541
+ target,
1542
+ scope,
1543
+ warnings
1544
+ );
1545
+ if (skill) skills.push(skill);
1546
+ }
1547
+ skills.sort((a, b) => compareNames(a.name, b.name));
1548
+ return { exists: true, skills };
1549
+ }
1550
+ async function discoverSkills(options) {
1551
+ const projectRoot = resolve2(options.projectRoot);
1552
+ const warnings = [];
1553
+ const targets = [];
1554
+ const totals = { global: 0, project: 0 };
1555
+ for (const spec of resolveSkillTargets(options)) {
1556
+ const globalScan = await scanSkillsDir(
1557
+ spec.globalDir,
1558
+ spec.id,
1559
+ "global",
1560
+ warnings
1561
+ );
1562
+ const projectScan = await scanSkillsDir(
1563
+ spec.projectDir,
1564
+ spec.id,
1565
+ "project",
1566
+ warnings
1567
+ );
1568
+ totals.global += globalScan.skills.length;
1569
+ totals.project += projectScan.skills.length;
1570
+ const skills = [...globalScan.skills, ...projectScan.skills].sort(
1571
+ (a, b) => compareNames(a.name, b.name)
1572
+ );
1573
+ targets.push({
1574
+ id: spec.id,
1575
+ label: spec.label,
1576
+ globalDir: spec.globalDir,
1577
+ projectDir: spec.projectDir,
1578
+ globalExists: globalScan.exists,
1579
+ projectExists: projectScan.exists,
1580
+ skills
1581
+ });
1582
+ }
1583
+ return {
1584
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1585
+ projectRoot,
1586
+ targets,
1587
+ totals,
1588
+ warnings
1589
+ };
1590
+ }
1591
+
1592
+ // src/studio/ui/client.ts
1593
+ var CLIENT_JS = `
1594
+ (function () {
1595
+ 'use strict';
1596
+
1597
+ var THEME_KEY = 'fwinit:theme';
1598
+ var metaEl = document.getElementById('studio-meta');
1599
+ var meta = metaEl ? JSON.parse(metaEl.textContent || '{}') : {};
1600
+
1601
+ var state = {
1602
+ view: 'skills',
1603
+ scope: 'global',
1604
+ skillKey: null,
1605
+ specPath: null,
1606
+ filter: { skills: '', specs: '' },
1607
+ skills: null,
1608
+ specs: null,
1609
+ doc: null,
1610
+ collapsed: {},
1611
+ expanded: {},
1612
+ tocLinks: []
1613
+ };
1614
+
1615
+ function q(sel, root) {
1616
+ return (root || document).querySelector(sel);
1617
+ }
1618
+
1619
+ function qa(sel, root) {
1620
+ return Array.prototype.slice.call((root || document).querySelectorAll(sel));
1621
+ }
1622
+
1623
+ function el(tag, props, children) {
1624
+ var node = document.createElement(tag);
1625
+ if (props) {
1626
+ Object.keys(props).forEach(function (key) {
1627
+ var value = props[key];
1628
+ if (value === undefined || value === null || value === false) return;
1629
+ if (key === 'class') node.className = value;
1630
+ else if (key === 'text') node.textContent = String(value);
1631
+ else if (key === 'dataset') {
1632
+ Object.keys(value).forEach(function (dataKey) {
1633
+ node.dataset[dataKey] = String(value[dataKey]);
1634
+ });
1635
+ } else if (key.indexOf('on') === 0 && typeof value === 'function') {
1636
+ node.addEventListener(key.slice(2).toLowerCase(), value);
1637
+ } else if (key === 'html') {
1638
+ node.innerHTML = value;
1639
+ } else {
1640
+ node.setAttribute(key, String(value));
1641
+ }
1642
+ });
1643
+ }
1644
+ (children || []).forEach(function (child) {
1645
+ if (child === null || child === undefined || child === false) return;
1646
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
1647
+ });
1648
+ return node;
1649
+ }
1650
+
1651
+ function fmtBytes(bytes) {
1652
+ if (typeof bytes !== 'number' || !isFinite(bytes)) return '\u2014';
1653
+ if (bytes < 1024) return bytes + ' B';
1654
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1655
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1656
+ }
1657
+
1658
+ function fmtDate(iso) {
1659
+ if (!iso) return '\u2014';
1660
+ var date = new Date(iso);
1661
+ if (isNaN(date.getTime())) return '\u2014';
1662
+ var pad = function (value) { return String(value).padStart(2, '0'); };
1663
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
1664
+ ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
1665
+ }
1666
+
1667
+ function shortPath(path) {
1668
+ var home = meta.home || '';
1669
+ if (home && path.indexOf(home) === 0) return '~' + path.slice(home.length);
1670
+ return path;
1671
+ }
1672
+
1673
+ function copyValue(value, button) {
1674
+ var run = function () {
1675
+ var done = function () {
1676
+ if (!button) return;
1677
+ var previous = button.getAttribute('data-label') || button.textContent;
1678
+ button.setAttribute('data-label', previous);
1679
+ button.setAttribute('data-copied', 'true');
1680
+ button.textContent = 'Copiado';
1681
+ setTimeout(function () {
1682
+ button.removeAttribute('data-copied');
1683
+ button.textContent = previous;
1684
+ }, 1400);
1685
+ };
1686
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1687
+ navigator.clipboard.writeText(value).then(done, function () {});
1688
+ return;
1689
+ }
1690
+ var area = el('textarea', { style: 'position:fixed;opacity:0' });
1691
+ area.value = value;
1692
+ document.body.appendChild(area);
1693
+ area.select();
1694
+ try { document.execCommand('copy'); done(); } catch (error) { /* ignore */ }
1695
+ document.body.removeChild(area);
1696
+ };
1697
+ run();
1698
+ }
1699
+
1700
+ function copyButton(label, value) {
1701
+ return el('button', {
1702
+ type: 'button',
1703
+ class: 'btn',
1704
+ 'aria-label': 'Copiar ' + label,
1705
+ text: label,
1706
+ onclick: function (event) { copyValue(value, event.currentTarget); }
1707
+ });
1708
+ }
1709
+
1710
+ /* \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1711
+
1712
+ function systemTheme() {
1713
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
1714
+ }
1715
+
1716
+ function currentTheme() {
1717
+ var stored = document.documentElement.dataset.theme;
1718
+ if (stored === 'light' || stored === 'dark') return stored;
1719
+ return systemTheme();
1720
+ }
1721
+
1722
+ function applyTheme(theme) {
1723
+ document.documentElement.dataset.theme = theme;
1724
+ try { localStorage.setItem(THEME_KEY, theme); } catch (error) { /* private mode */ }
1725
+ var button = q('[data-theme-toggle]');
1726
+ if (!button) return;
1727
+ var isLight = theme === 'light';
1728
+ button.setAttribute('aria-pressed', isLight ? 'true' : 'false');
1729
+ button.setAttribute('aria-label', isLight ? 'Cambiar al tema oscuro' : 'Cambiar al tema claro');
1730
+ }
1731
+
1732
+ function toggleTheme() {
1733
+ applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
1734
+ }
1735
+
1736
+ /* \u2500\u2500 Data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1737
+
1738
+ function fetchJson(url) {
1739
+ return fetch(url, { headers: { accept: 'application/json' } }).then(function (response) {
1740
+ if (response.ok) return response.json();
1741
+ return response.json().catch(function () { return {}; }).then(function (body) {
1742
+ var error = new Error(body.error || 'La solicitud fall\xF3 (' + response.status + ')');
1743
+ error.status = response.status;
1744
+ throw error;
1745
+ });
1746
+ });
1747
+ }
1748
+
1749
+ function loadSkills() {
1750
+ if (state.skills) return Promise.resolve(state.skills);
1751
+ return fetchJson('/api/skills').then(function (data) {
1752
+ state.skills = data;
1753
+ return data;
1754
+ });
1755
+ }
1756
+
1757
+ function loadSpecs() {
1758
+ if (state.specs) return Promise.resolve(state.specs);
1759
+ return fetchJson('/api/specs').then(function (data) {
1760
+ state.specs = data;
1761
+ return data;
1762
+ });
1763
+ }
1764
+
1765
+ /* \u2500\u2500 Routing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1766
+
1767
+ function route() {
1768
+ var raw = (location.hash || '#/skills').replace(/^#\\/?/, '');
1769
+ var parts = raw.split('/').map(function (part) {
1770
+ try { return decodeURIComponent(part); } catch (error) { return part; }
1771
+ });
1772
+ state.view = parts[0] === 'specs' ? 'specs' : 'skills';
1773
+ if (state.view === 'skills') {
1774
+ if (parts.length >= 4) {
1775
+ state.scope = parts[1] === 'project' ? 'project' : 'global';
1776
+ state.skillKey = parts.slice(2).join('/');
1777
+ }
1778
+ } else {
1779
+ var nextPath = parts.slice(1).join('/') || null;
1780
+ if (state.doc && state.doc.path !== nextPath) state.doc = null;
1781
+ state.specPath = nextPath;
1782
+ }
1783
+ render();
1784
+ // Cambiar de ruta tiene que pedir el documento: render() s\xF3lo dibuja el
1785
+ // estado que ya tiene en memoria.
1786
+ if (state.view === 'specs') loadSpecDoc();
1787
+ }
1788
+
1789
+ function navigate(hash) {
1790
+ if (location.hash === hash) render();
1791
+ else location.hash = hash;
1792
+ }
1793
+
1794
+ function skillHash(target, name) {
1795
+ return '#/skills/' + state.scope + '/' + encodeURIComponent(target) + '/' + encodeURIComponent(name);
1796
+ }
1797
+
1798
+ function specHash(path) {
1799
+ return '#/specs/' + path.split('/').map(encodeURIComponent).join('/');
1800
+ }
1801
+
1802
+ /* \u2500\u2500 Shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1803
+
1804
+ function syncShell() {
1805
+ qa('[data-view]').forEach(function (tab) {
1806
+ tab.setAttribute('aria-selected', tab.dataset.view === state.view ? 'true' : 'false');
1807
+ });
1808
+ qa('[data-view-panel]').forEach(function (panel) {
1809
+ panel.dataset.active = panel.dataset.viewPanel === state.view ? 'true' : 'false';
1810
+ });
1811
+ var skillsCount = q('[data-count="skills"]');
1812
+ var specsCount = q('[data-count="specs"]');
1813
+ if (skillsCount && state.skills) {
1814
+ skillsCount.textContent = String(state.skills.totals.global + state.skills.totals.project);
1815
+ }
1816
+ if (specsCount && state.specs) {
1817
+ specsCount.textContent = String(state.specs.totals.files);
1818
+ }
1819
+ var counts = q('[data-status-counts]');
1820
+ if (counts) {
1821
+ var bits = [];
1822
+ if (state.skills) bits.push(state.skills.totals.global + ' skills globales');
1823
+ if (state.skills) bits.push(state.skills.totals.project + ' skills del proyecto');
1824
+ if (state.specs) bits.push(state.specs.totals.files + ' archivos de specs');
1825
+ counts.textContent = bits.join(' \xB7 ');
1826
+ }
1827
+ var root = q('[data-status-root]');
1828
+ if (root) {
1829
+ root.textContent = shortPath(meta.projectRoot || '');
1830
+ root.title = meta.projectRoot || '';
1831
+ }
1832
+ }
1833
+
1834
+ function emptyState(options) {
1835
+ return el('div', { class: 'empty' }, [
1836
+ el('p', { class: 'empty__title', text: options.title }),
1837
+ options.text ? el('p', { class: 'empty__text', text: options.text }) : null,
1838
+ options.path ? el('p', { class: 'empty__path', text: options.path }) : null,
1839
+ options.hint ? el('p', { class: 'empty__hint', text: options.hint }) : null
1840
+ ]);
1841
+ }
1842
+
1843
+ function renderError(container, error) {
1844
+ container.textContent = '';
1845
+ container.appendChild(emptyState({
1846
+ title: 'El studio no pudo leer esto',
1847
+ text: error && error.message ? error.message : 'Error desconocido.',
1848
+ hint: 'Volv\xE9 a ejecutar fwinit studio desde la ra\xEDz del proyecto.'
1849
+ }));
1850
+ }
1851
+
1852
+ /* \u2500\u2500 Skills view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1853
+
1854
+ function visibleSkills(report, scope) {
1855
+ var filter = state.filter.skills.trim().toLowerCase();
1856
+ var out = [];
1857
+ (report.targets || []).forEach(function (target) {
1858
+ (target.skills || []).forEach(function (skill) {
1859
+ if (skill.scope !== scope) return;
1860
+ if (filter) {
1861
+ var haystack = (skill.name + ' ' + (skill.description || '') + ' ' + (skill.version || '')).toLowerCase();
1862
+ if (haystack.indexOf(filter) === -1) return;
1863
+ }
1864
+ out.push(skill);
1865
+ });
1866
+ });
1867
+ return out;
1868
+ }
1869
+
1870
+ function skillMetaLine(skill) {
1871
+ var bits = [];
1872
+ bits.push(skill.files + (skill.files === 1 ? ' archivo' : ' archivos'));
1873
+ if (skill.version) bits.push('v' + skill.version);
1874
+ if (skill.hasCommand) bits.push('command');
1875
+ return bits.join(' \xB7 ');
1876
+ }
1877
+
1878
+ function renderSkillList() {
1879
+ var container = q('[data-list="skills"]');
1880
+ if (!container) return;
1881
+ container.textContent = '';
1882
+ var report = state.skills;
1883
+ if (!report) {
1884
+ container.appendChild(el('p', { class: 'loading', text: 'Leyendo las skills instaladas\u2026' }));
1885
+ return;
1886
+ }
1887
+ var shown = visibleSkills(report, state.scope);
1888
+ if (!shown.length) {
1889
+ container.appendChild(emptyState({
1890
+ title: state.filter.skills ? 'Ninguna skill coincide con el filtro' : 'No hay skills en este alcance',
1891
+ text: state.filter.skills
1892
+ ? 'Limpi\xE1 el filtro para ver todo lo instalado.'
1893
+ : 'Instalalas con: fwinit skills --global ' + state.scope,
1894
+ hint: String(shown.length) + ' de ' + String(report.totals[state.scope]) + ' en este alcance'
1895
+ }));
1896
+ }
1897
+ (report.targets || []).forEach(function (target) {
1898
+ var skills = (target.skills || []).filter(function (skill) {
1899
+ return skill.scope === state.scope && shown.indexOf(skill) !== -1;
1900
+ });
1901
+ var dir = state.scope === 'global' ? target.globalDir : target.projectDir;
1902
+ var exists = state.scope === 'global' ? target.globalExists : target.projectExists;
1903
+ var collapsed = state.collapsed[state.scope + ':' + target.id] === true;
1904
+ var group = el('div', { class: 'group' });
1905
+
1906
+ group.appendChild(el('button', {
1907
+ type: 'button',
1908
+ class: 'group__head',
1909
+ 'aria-expanded': collapsed ? 'false' : 'true',
1910
+ onclick: function () {
1911
+ state.collapsed[state.scope + ':' + target.id] = !collapsed;
1912
+ renderSkillList();
1913
+ }
1914
+ }, [
1915
+ target.label,
1916
+ el('span', { class: 'group__count', text: String(skills.length) })
1917
+ ]));
1918
+
1919
+ if (!collapsed) {
1920
+ group.appendChild(el('p', { class: 'group__path', title: dir, text: shortPath(dir) }));
1921
+ if (!exists || !skills.length) {
1922
+ group.appendChild(el('p', {
1923
+ class: 'row--empty',
1924
+ text: exists ? 'No hay skills ac\xE1' : 'No instalado'
1925
+ }));
1926
+ }
1927
+ skills.forEach(function (skill) {
1928
+ var key = skill.target + '/' + skill.name;
1929
+ group.appendChild(el('button', {
1930
+ type: 'button',
1931
+ class: 'row',
1932
+ 'aria-current': state.skillKey === key ? 'true' : 'false',
1933
+ title: skill.description || skill.name,
1934
+ onclick: function () { navigate(skillHash(skill.target, skill.name)); }
1935
+ }, [
1936
+ el('span', { class: 'row__name', text: skill.name }),
1937
+ el('span', { class: 'row__meta', text: skillMetaLine(skill) })
1938
+ ]));
1939
+ });
1940
+ }
1941
+ container.appendChild(group);
1942
+ });
1943
+ // Avisos del reporte (directorios ilegibles o symlinks salteados): sin esto
1944
+ // una skill salteada desaparece de la lista sin explicaci\xF3n.
1945
+ (report.warnings || []).forEach(function (warning) {
1946
+ container.appendChild(el('p', { class: 'row--empty', text: 'Aviso: ' + warning }));
1947
+ });
1948
+ }
1949
+
1950
+ function selectedSkill() {
1951
+ if (!state.skills || !state.skillKey) return null;
1952
+ var parts = state.skillKey.split('/');
1953
+ var target = parts[0];
1954
+ var name = parts.slice(1).join('/');
1955
+ var found = null;
1956
+ (state.skills.targets || []).forEach(function (entry) {
1957
+ if (entry.id !== target) return;
1958
+ (entry.skills || []).forEach(function (skill) {
1959
+ if (skill.scope !== state.scope) return;
1960
+ if (skill.name !== name) return;
1961
+ found = skill;
1962
+ });
1963
+ });
1964
+ return found;
1965
+ }
1966
+
1967
+ function metaRow(key, value, strong) {
1968
+ return el('div', { class: 'meta__row' }, [
1969
+ el('span', { class: 'meta__key', text: key }),
1970
+ el('span', { class: 'meta__value' + (strong ? ' meta__value--strong' : ''), text: value === undefined || value === '' ? '\u2014' : String(value) })
1971
+ ]);
1972
+ }
1973
+
1974
+ function renderSkillDetail() {
1975
+ var container = q('[data-detail="skills"]');
1976
+ if (!container) return;
1977
+ container.textContent = '';
1978
+ if (!state.skills) {
1979
+ container.appendChild(el('p', { class: 'loading', text: 'Leyendo las skills instaladas\u2026' }));
1980
+ return;
1981
+ }
1982
+ var skill = selectedSkill();
1983
+ if (!skill) {
1984
+ var target = (state.skills.targets || [])[0] || null;
1985
+ var dir = target ? (state.scope === 'global' ? target.globalDir : target.projectDir) : '';
1986
+ container.appendChild(emptyState({
1987
+ title: 'Eleg\xED una skill',
1988
+ text: 'Las skills de este alcance est\xE1n a la izquierda, agrupadas por la herramienta que las lee.',
1989
+ path: dir ? shortPath(dir) : undefined,
1990
+ hint: 'fwinit skills --global opencode|pi|gentle-shell'
1991
+ }));
1992
+ return;
1993
+ }
1994
+ var report = null;
1995
+ (state.skills.targets || []).forEach(function (entry) {
1996
+ if (entry.id === skill.target) report = entry;
1997
+ });
1998
+
1999
+ container.appendChild(el('header', null, [
2000
+ el('p', { class: 'eyebrow', text: (report ? report.label : skill.target) + ' \xB7 ' + skill.scope }),
2001
+ el('h1', { class: 'detail__title', text: skill.name }),
2002
+ el('p', { class: 'detail__lede', text: skill.description || 'Esta skill no tiene descripci\xF3n en su frontmatter.' }),
2003
+ el('div', { class: 'detail__actions' }, [
2004
+ copyButton('Copiar ruta', skill.path),
2005
+ copyButton('Copiar nombre', skill.name),
2006
+ skill.version ? el('span', { class: 'badge badge--accent', text: 'v' + skill.version }) : null,
2007
+ el('span', { class: 'badge badge--muted', text: skill.files + (skill.files === 1 ? ' archivo' : ' archivos') }),
2008
+ skill.hasCommand ? el('span', { class: 'badge badge--muted', text: 'command.md' }) : null
2009
+ ])
2010
+ ]));
2011
+
2012
+ container.appendChild(el('div', { class: 'meta' }, [
2013
+ metaRow('Directorio', shortPath(skill.dir), true),
2014
+ metaRow('Archivo', skill.path.replace(meta.projectRoot || '', '')),
2015
+ metaRow('Destino', report ? report.label : skill.target),
2016
+ metaRow('Alcance', skill.scope === 'global' ? 'instalaci\xF3n global del usuario' : 'este proyecto'),
2017
+ metaRow('Versi\xF3n', skill.version),
2018
+ metaRow('Licencia', skill.license),
2019
+ metaRow('Autor', skill.author),
2020
+ metaRow('Archivos', String(skill.files)),
2021
+ metaRow('Tama\xF1o', fmtBytes(skill.bytes)),
2022
+ metaRow('Modificado', fmtDate(skill.modified))
2023
+ ]));
2024
+
2025
+ if (skill.warnings && skill.warnings.length) {
2026
+ container.appendChild(el('div', { class: 'meta' }, skill.warnings.map(function (warning) {
2027
+ return el('div', { class: 'meta__row' }, [
2028
+ el('span', { class: 'meta__key', text: 'Aviso' }),
2029
+ el('span', { class: 'meta__value', text: warning })
2030
+ ]);
2031
+ })));
2032
+ }
2033
+ }
2034
+
2035
+ /* \u2500\u2500 Specs view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2036
+
2037
+ function matchesFilter(node, filter) {
2038
+ if (!filter) return true;
2039
+ if (node.path.toLowerCase().indexOf(filter) !== -1) return true;
2040
+ return (node.children || []).some(function (child) { return matchesFilter(child, filter); });
2041
+ }
2042
+
2043
+ function renderTreeNodes(nodes, depth, container) {
2044
+ var filter = state.filter.specs.trim().toLowerCase();
2045
+ (nodes || []).forEach(function (node) {
2046
+ if (!matchesFilter(node, filter)) return;
2047
+ if (node.type === 'dir') {
2048
+ var key = node.path;
2049
+ var open = filter ? true : (state.expanded[key] === true || (state.expanded[key] !== false && depth < 2));
2050
+ container.appendChild(el('button', {
2051
+ type: 'button',
2052
+ class: 'row row--dir',
2053
+ 'aria-expanded': open ? 'true' : 'false',
2054
+ onclick: function () {
2055
+ state.expanded[key] = !open;
2056
+ renderSpecTree();
2057
+ }
2058
+ }, [
2059
+ el('span', { class: 'tree__toggle' }, [
2060
+ el('span', { class: 'tree__chevron', dataset: { open: open ? 'true' : 'false' }, text: '\u203A' }),
2061
+ node.name
2062
+ ]),
2063
+ el('span', { class: 'row__meta', text: String(countFiles(node)) + ' arch.' })
2064
+ ]));
2065
+ if (open) {
2066
+ var kids = el('div', { class: 'tree__children' });
2067
+ renderTreeNodes(node.children, depth + 1, kids);
2068
+ container.appendChild(kids);
2069
+ }
2070
+ return;
2071
+ }
2072
+ container.appendChild(el('button', {
2073
+ type: 'button',
2074
+ class: 'row row--file',
2075
+ 'aria-current': state.specPath === node.path ? 'true' : 'false',
2076
+ title: node.path,
2077
+ onclick: function () { navigate(specHash(node.path)); }
2078
+ }, [
2079
+ el('span', { class: 'row__name', text: node.name }),
2080
+ el('span', { class: 'row__meta', text: fmtBytes(node.bytes) })
2081
+ ]));
2082
+ });
2083
+ }
2084
+
2085
+ function countFiles(node) {
2086
+ if (node.type === 'file') return 1;
2087
+ var total = 0;
2088
+ (node.children || []).forEach(function (child) { total += countFiles(child); });
2089
+ return total;
2090
+ }
2091
+
2092
+ function renderSpecTree() {
2093
+ var container = q('[data-list="specs"]');
2094
+ if (!container) return;
2095
+ container.textContent = '';
2096
+ var specs = state.specs;
2097
+ if (!specs) {
2098
+ container.appendChild(el('p', { class: 'loading', text: 'Buscando specs/\u2026' }));
2099
+ return;
2100
+ }
2101
+ if (!specs.found) {
2102
+ container.appendChild(el('p', { class: 'row--empty', text: 'Este proyecto no tiene carpeta specs/' }));
2103
+ return;
2104
+ }
2105
+ if (!specs.tree.length) {
2106
+ container.appendChild(el('p', { class: 'row--empty', text: 'specs/ est\xE1 vac\xEDa' }));
2107
+ return;
2108
+ }
2109
+ var tree = el('div', { class: 'tree' });
2110
+ renderTreeNodes(specs.tree, 0, tree);
2111
+ container.appendChild(tree);
2112
+ }
2113
+
2114
+ var SANITIZE_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'form', 'base', 'noscript'];
2115
+
2116
+ function sanitize(html) {
2117
+ var doc = new DOMParser().parseFromString('<div id="studio-root">' + html + '</div>', 'text/html');
2118
+ var root = doc.getElementById('studio-root');
2119
+ if (!root) return '';
2120
+ qa(SANITIZE_TAGS.join(','), root).forEach(function (node) { node.remove(); });
2121
+ qa('*', root).forEach(function (node) {
2122
+ Array.prototype.slice.call(node.attributes).forEach(function (attribute) {
2123
+ var name = attribute.name.toLowerCase();
2124
+ if (name.indexOf('on') === 0 || name === 'style' || name === 'srcdoc') {
2125
+ node.removeAttribute(attribute.name);
2126
+ return;
2127
+ }
2128
+ if (name === 'href' || name === 'src' || name === 'xlink:href') {
2129
+ var value = String(attribute.value).trim().toLowerCase();
2130
+ var safe = value.indexOf('http://') === 0 || value.indexOf('https://') === 0 ||
2131
+ value.indexOf('mailto:') === 0 || value.indexOf('#') === 0;
2132
+ if (!safe) node.removeAttribute(attribute.name);
2133
+ }
2134
+ });
2135
+ });
2136
+ return root.innerHTML;
2137
+ }
2138
+
2139
+ function renderToc(headings) {
2140
+ var list = q('[data-toc-list]');
2141
+ if (!list) return;
2142
+ list.textContent = '';
2143
+ state.tocLinks = [];
2144
+ var items = (headings || []).filter(function (heading) { return heading.level >= 2 && heading.level <= 3; });
2145
+ if (!items.length) {
2146
+ list.appendChild(el('p', { class: 'empty__hint', text: 'Sin secciones' }));
2147
+ return;
2148
+ }
2149
+ items.forEach(function (heading) {
2150
+ var link = el('a', {
2151
+ class: 'toc__link',
2152
+ href: '#' + heading.id,
2153
+ dataset: { level: String(heading.level), target: heading.id },
2154
+ text: heading.text,
2155
+ onclick: function (event) {
2156
+ event.preventDefault();
2157
+ var target = document.getElementById(heading.id);
2158
+ if (target) target.scrollIntoView({ block: 'start', behavior: 'smooth' });
2159
+ }
2160
+ });
2161
+ list.appendChild(link);
2162
+ state.tocLinks.push(link);
2163
+ });
2164
+ }
2165
+
2166
+ function renderSpecDoc() {
2167
+ var container = q('[data-detail="specs"]');
2168
+ if (!container) return;
2169
+ var specs = state.specs;
2170
+ if (!specs) {
2171
+ container.textContent = '';
2172
+ container.appendChild(el('p', { class: 'loading', text: 'Buscando specs/\u2026' }));
2173
+ return;
2174
+ }
2175
+ if (!specs.found) {
2176
+ container.textContent = '';
2177
+ container.appendChild(emptyState({
2178
+ title: 'Este proyecto todav\xEDa no tiene specs/',
2179
+ text: 'El studio busca specs/ en la ra\xEDz del proyecto. Creala con la skill create-specs y recarg\xE1 la p\xE1gina.',
2180
+ path: specs.projectRoot + '/specs',
2181
+ hint: 'Skill: create-specs \xB7 /create-specs'
2182
+ }));
2183
+ renderToc([]);
2184
+ return;
2185
+ }
2186
+ var doc = state.doc;
2187
+ if (!doc) {
2188
+ container.textContent = '';
2189
+ container.appendChild(
2190
+ state.specPath ? el('p', { class: 'loading', text: 'Leyendo la spec\u2026' }) : emptyState({
2191
+ title: 'Eleg\xED un documento',
2192
+ text: 'El \xE1rbol de la izquierda es el specs/ de este proyecto, ordenado como lo escribe la skill create-specs.',
2193
+ path: specs.root ? shortPath(specs.root) : undefined,
2194
+ hint: specs.totals.files + ' archivos \xB7 ' + fmtBytes(specs.totals.bytes)
2195
+ })
2196
+ );
2197
+ renderToc([]);
2198
+ return;
2199
+ }
2200
+ container.textContent = '';
2201
+ container.appendChild(el('header', { class: 'doc-head' }, [
2202
+ el('p', { class: 'doc-head__path', text: 'specs/' + doc.path }),
2203
+ el('h1', { class: 'doc-head__title', text: doc.title }),
2204
+ el('div', { class: 'doc-head__stats' }, [
2205
+ el('span', { text: fmtBytes(doc.bytes) }),
2206
+ el('span', { text: 'modificado ' + fmtDate(doc.modified) }),
2207
+ el('span', { text: (doc.headings ? doc.headings.length : 0) + ' t\xEDtulos' }),
2208
+ copyButton('Copiar ruta', doc.absolutePath)
2209
+ ])
2210
+ ]));
2211
+ var article = el('article', { class: 'doc' });
2212
+ article.innerHTML = sanitize(doc.html || '');
2213
+ // El t\xEDtulo ya vive en la cabecera del documento: repetirlo en el cuerpo
2214
+ // deja dos h1 iguales en la misma p\xE1gina.
2215
+ var duplicateTitle = q('h1', article);
2216
+ if (duplicateTitle && duplicateTitle.textContent.trim() === doc.title.trim()) {
2217
+ duplicateTitle.remove();
2218
+ }
2219
+ qa('[data-spec-link]', article).forEach(function (link) {
2220
+ link.addEventListener('click', function (event) {
2221
+ event.preventDefault();
2222
+ var target = link.getAttribute('data-spec-link');
2223
+ if (!target) return;
2224
+ // El server ya reescribi\xF3 el target como path desde specs/: resolverlo
2225
+ // otra vez contra doc.path duplicar\xEDa el prefijo (modules/x \u2192 docs/modules/x).
2226
+ navigate(specHash(target));
2227
+ });
2228
+ });
2229
+ container.appendChild(article);
2230
+ if (doc.warnings && doc.warnings.length) {
2231
+ container.appendChild(el('div', { class: 'meta' }, doc.warnings.map(function (warning) {
2232
+ return el('div', { class: 'meta__row' }, [
2233
+ el('span', { class: 'meta__key', text: 'Aviso' }),
2234
+ el('span', { class: 'meta__value', text: warning })
2235
+ ]);
2236
+ })));
2237
+ }
2238
+ renderToc(doc.headings);
2239
+ container.scrollTop = 0;
2240
+ }
2241
+
2242
+ function loadSpecDoc() {
2243
+ if (!state.specPath) {
2244
+ state.doc = null;
2245
+ renderSpecDoc();
2246
+ return;
2247
+ }
2248
+ var path = state.specPath;
2249
+ q('[data-list="specs"]');
2250
+ fetchJson('/api/specs/file?path=' + encodeURIComponent(path)).then(function (doc) {
2251
+ if (state.specPath !== path) return;
2252
+ state.doc = doc;
2253
+ renderSpecDoc();
2254
+ renderSpecTree();
2255
+ }, function (error) {
2256
+ if (state.specPath !== path) return;
2257
+ state.doc = null;
2258
+ renderSpecDoc();
2259
+ var container = q('[data-detail="specs"]');
2260
+ if (container) {
2261
+ container.textContent = '';
2262
+ renderError(container, error);
2263
+ }
2264
+ });
2265
+ }
2266
+
2267
+ /* \u2500\u2500 Render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2268
+
2269
+ function render() {
2270
+ syncShell();
2271
+ if (state.view === 'skills') {
2272
+ renderSkillList();
2273
+ renderSkillDetail();
2274
+ return;
2275
+ }
2276
+ renderSpecTree();
2277
+ renderSpecDoc();
2278
+ }
2279
+
2280
+ function bootSkills() {
2281
+ loadSkills().then(function () {
2282
+ // Elegir la primera skill no debe reescribir la URL: si el usuario lleg\xF3
2283
+ // por un deep link a /specs, el hash ya es la ruta que eligi\xF3.
2284
+ if (!state.skillKey) {
2285
+ var first = visibleSkills(state.skills, state.scope)[0];
2286
+ if (first) state.skillKey = first.target + '/' + first.name;
2287
+ }
2288
+ renderSkillList();
2289
+ renderSkillDetail();
2290
+ syncShell();
2291
+ }, function (error) {
2292
+ var list = q('[data-list="skills"]');
2293
+ var detail = q('[data-detail="skills"]');
2294
+ if (list) { list.textContent = ''; renderError(list, error); }
2295
+ if (detail) { detail.textContent = ''; renderError(detail, error); }
2296
+ });
2297
+ }
2298
+
2299
+ function bootSpecs() {
2300
+ loadSpecs().then(function (specs) {
2301
+ renderSpecTree();
2302
+ syncShell();
2303
+ if (!state.specPath && specs.found) {
2304
+ var firstFile = null;
2305
+ var walk = function (nodes) {
2306
+ (nodes || []).forEach(function (node) {
2307
+ if (firstFile) return;
2308
+ if (node.type === 'file') { firstFile = node; return; }
2309
+ walk(node.children);
2310
+ });
2311
+ };
2312
+ walk(specs.tree);
2313
+ if (firstFile) state.specPath = firstFile.path;
2314
+ }
2315
+ loadSpecDoc();
2316
+ }, function (error) {
2317
+ var list = q('[data-list="specs"]');
2318
+ var detail = q('[data-detail="specs"]');
2319
+ if (list) { list.textContent = ''; renderError(list, error); }
2320
+ if (detail) { detail.textContent = ''; renderError(detail, error); }
2321
+ });
2322
+ }
2323
+
2324
+ function boot() {
2325
+ applyTheme(currentTheme());
2326
+
2327
+ var toggle = q('[data-theme-toggle]');
2328
+ if (toggle) toggle.addEventListener('click', toggleTheme);
2329
+
2330
+ qa('[data-view]').forEach(function (tab) {
2331
+ tab.addEventListener('click', function () { navigate('#/' + tab.dataset.view); });
2332
+ });
2333
+
2334
+ qa('[data-scope]').forEach(function (button) {
2335
+ button.addEventListener('click', function () {
2336
+ state.scope = button.dataset.scope;
2337
+ state.skillKey = null;
2338
+ qa('[data-scope]').forEach(function (other) {
2339
+ other.setAttribute('aria-pressed', other.dataset.scope === state.scope ? 'true' : 'false');
2340
+ });
2341
+ var first = state.skills ? visibleSkills(state.skills, state.scope)[0] : null;
2342
+ if (first) {
2343
+ state.skillKey = first.target + '/' + first.name;
2344
+ history.replaceState(null, '', skillHash(first.target, first.name));
2345
+ }
2346
+ renderSkillList();
2347
+ renderSkillDetail();
2348
+ syncShell();
2349
+ });
2350
+ });
2351
+
2352
+ qa('[data-search]').forEach(function (input) {
2353
+ input.addEventListener('input', function () {
2354
+ state.filter[input.dataset.search] = input.value;
2355
+ if (input.dataset.search === 'skills') {
2356
+ renderSkillList();
2357
+ return;
2358
+ }
2359
+ renderSpecTree();
2360
+ });
2361
+ });
2362
+
2363
+ document.addEventListener('keydown', function (event) {
2364
+ var target = event.target;
2365
+ var typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
2366
+ if (event.key === '/' && !typing) {
2367
+ event.preventDefault();
2368
+ var input = q('[data-search="' + state.view + '"]');
2369
+ if (input) input.focus();
2370
+ return;
2371
+ }
2372
+ if (event.key === 'Escape' && typing) {
2373
+ target.value = '';
2374
+ state.filter[state.view] = '';
2375
+ render();
2376
+ target.blur();
2377
+ return;
2378
+ }
2379
+ if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
2380
+ if (event.key === 't') { toggleTheme(); return; }
2381
+ if (event.key === '1') { navigate('#/skills'); return; }
2382
+ if (event.key === '2') { navigate('#/specs'); return; }
2383
+ });
2384
+
2385
+ var pane = q('[data-detail="specs"]');
2386
+ if (pane) {
2387
+ pane.addEventListener('scroll', function () {
2388
+ if (!state.tocLinks.length) return;
2389
+ var active = null;
2390
+ for (var i = 0; i < state.tocLinks.length; i++) {
2391
+ var heading = document.getElementById(state.tocLinks[i].dataset.target);
2392
+ if (!heading) continue;
2393
+ if (heading.getBoundingClientRect().top - pane.getBoundingClientRect().top <= 24) active = state.tocLinks[i];
2394
+ }
2395
+ state.tocLinks.forEach(function (link) {
2396
+ link.setAttribute('aria-current', link === active ? 'true' : 'false');
2397
+ });
2398
+ });
2399
+ }
2400
+
2401
+ window.addEventListener('hashchange', route);
2402
+
2403
+ route();
2404
+ bootSkills();
2405
+ bootSpecs();
2406
+ }
2407
+
2408
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
2409
+ else boot();
2410
+ })();
2411
+ `;
2412
+
2413
+ // src/studio/ui/styles.ts
2414
+ var STUDIO_CSS = `
2415
+ *,
2416
+ *::before,
2417
+ *::after {
2418
+ box-sizing: border-box;
2419
+ }
2420
+
2421
+ * {
2422
+ margin: 0;
2423
+ }
2424
+
2425
+ html {
2426
+ -webkit-text-size-adjust: 100%;
2427
+ }
2428
+
2429
+ body {
2430
+ min-height: 100vh;
2431
+ background-color: var(--base);
2432
+ color: var(--text);
2433
+ font-family: var(--font-sans);
2434
+ font-size: var(--text-small);
2435
+ line-height: var(--leading-body);
2436
+ font-synthesis-weight: none;
2437
+ -webkit-font-smoothing: antialiased;
2438
+ -moz-osx-font-smoothing: grayscale;
2439
+ transition:
2440
+ background-color var(--duration-base) ease,
2441
+ color var(--duration-base) ease;
2442
+ }
2443
+
2444
+ svg {
2445
+ display: block;
2446
+ flex: none;
2447
+ }
2448
+
2449
+ a {
2450
+ color: var(--accent);
2451
+ text-decoration: underline;
2452
+ text-decoration-thickness: 1px;
2453
+ text-underline-offset: 0.2em;
2454
+ transition: color var(--duration-fast) ease;
2455
+ }
2456
+
2457
+ a:hover {
2458
+ color: var(--accent-hover);
2459
+ }
2460
+
2461
+ a:not(:hover) {
2462
+ text-decoration-color: var(--accent-border);
2463
+ }
2464
+
2465
+ button,
2466
+ input {
2467
+ font: inherit;
2468
+ color: inherit;
2469
+ }
2470
+
2471
+ button {
2472
+ background: none;
2473
+ border: 0;
2474
+ cursor: pointer;
2475
+ }
2476
+
2477
+ :focus-visible {
2478
+ outline: 2px solid var(--focus-ring);
2479
+ outline-offset: 2px;
2480
+ }
2481
+
2482
+ ::selection {
2483
+ background: var(--accent-soft);
2484
+ color: var(--text);
2485
+ }
2486
+
2487
+ .skip {
2488
+ position: absolute;
2489
+ left: -9999px;
2490
+ top: 0;
2491
+ z-index: 60;
2492
+ background: var(--surface-elevated);
2493
+ border: 1px solid var(--accent-border);
2494
+ padding: var(--space-2xs) var(--space-xs);
2495
+ font-family: var(--font-mono);
2496
+ font-size: var(--text-caption);
2497
+ }
2498
+
2499
+ .skip:focus {
2500
+ left: var(--space-sm);
2501
+ top: var(--space-2xs);
2502
+ }
2503
+
2504
+ /* \u2500\u2500 Top bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2505
+ .topbar {
2506
+ position: fixed;
2507
+ inset: 0 0 auto 0;
2508
+ z-index: 50;
2509
+ height: var(--header-height);
2510
+ background: var(--base);
2511
+ border-bottom: var(--hairline-strong);
2512
+ }
2513
+
2514
+ .topbar__inner {
2515
+ display: grid;
2516
+ grid-template-columns: auto 1fr auto;
2517
+ align-items: center;
2518
+ gap: var(--space-md);
2519
+ height: 100%;
2520
+ max-width: var(--container);
2521
+ margin-inline: auto;
2522
+ padding-inline: var(--container-pad);
2523
+ }
2524
+
2525
+ .brand {
2526
+ display: inline-flex;
2527
+ align-items: center;
2528
+ gap: var(--space-2xs);
2529
+ color: var(--text);
2530
+ text-decoration: none;
2531
+ font-family: var(--font-mono);
2532
+ font-size: var(--text-small);
2533
+ font-weight: 600;
2534
+ letter-spacing: -0.01em;
2535
+ }
2536
+
2537
+ .brand:hover {
2538
+ color: var(--text);
2539
+ }
2540
+
2541
+ .brand__glyph {
2542
+ display: grid;
2543
+ place-items: center;
2544
+ width: 28px;
2545
+ height: 28px;
2546
+ background: var(--accent-soft);
2547
+ border: 1px solid var(--accent-border);
2548
+ color: var(--accent);
2549
+ font-size: var(--text-caption);
2550
+ font-weight: 700;
2551
+ }
2552
+
2553
+ .brand__div {
2554
+ color: var(--text-muted);
2555
+ font-weight: 400;
2556
+ }
2557
+
2558
+ .brand__page {
2559
+ color: var(--accent);
2560
+ font-weight: 500;
2561
+ }
2562
+
2563
+ .tabs {
2564
+ display: flex;
2565
+ align-items: center;
2566
+ justify-content: center;
2567
+ gap: var(--space-md);
2568
+ height: 100%;
2569
+ }
2570
+
2571
+ .tab {
2572
+ position: relative;
2573
+ display: inline-flex;
2574
+ align-items: center;
2575
+ gap: var(--space-2xs);
2576
+ height: 100%;
2577
+ color: var(--text-secondary);
2578
+ font-size: var(--text-small);
2579
+ font-weight: 500;
2580
+ transition: color var(--duration-fast) ease;
2581
+ }
2582
+
2583
+ .tab::after {
2584
+ content: '';
2585
+ position: absolute;
2586
+ inset: auto 0 0 0;
2587
+ height: 2px;
2588
+ background: var(--accent);
2589
+ transform: scaleX(0);
2590
+ transform-origin: left;
2591
+ transition: transform var(--duration-base) var(--ease-out);
2592
+ }
2593
+
2594
+ .tab:hover {
2595
+ color: var(--text);
2596
+ }
2597
+
2598
+ .tab[aria-selected='true'] {
2599
+ color: var(--text);
2600
+ }
2601
+
2602
+ .tab[aria-selected='true']::after {
2603
+ transform: scaleX(1);
2604
+ }
2605
+
2606
+ .tab__count {
2607
+ font-family: var(--font-mono);
2608
+ font-size: var(--text-caption);
2609
+ color: var(--text-muted);
2610
+ }
2611
+
2612
+ .tab[aria-selected='true'] .tab__count {
2613
+ color: var(--accent);
2614
+ }
2615
+
2616
+ .topbar__actions {
2617
+ display: flex;
2618
+ align-items: center;
2619
+ gap: var(--space-2xs);
2620
+ }
2621
+
2622
+ .badge {
2623
+ display: inline-flex;
2624
+ align-items: center;
2625
+ gap: var(--space-3xs);
2626
+ padding: 0.125rem var(--space-2xs);
2627
+ font-family: var(--font-mono);
2628
+ font-size: var(--text-caption);
2629
+ line-height: 1.5;
2630
+ white-space: nowrap;
2631
+ }
2632
+
2633
+ .badge--version {
2634
+ color: var(--text-secondary);
2635
+ background: var(--surface);
2636
+ border: 1px solid var(--border);
2637
+ }
2638
+
2639
+ .badge--accent {
2640
+ color: var(--accent);
2641
+ background: var(--accent-soft);
2642
+ border: 1px solid var(--accent-border);
2643
+ }
2644
+
2645
+ .badge--muted {
2646
+ color: var(--text-muted);
2647
+ background: var(--surface);
2648
+ border: 1px solid var(--border);
2649
+ }
2650
+
2651
+ .btn {
2652
+ display: inline-flex;
2653
+ align-items: center;
2654
+ gap: var(--space-2xs);
2655
+ padding: var(--space-3xs) var(--space-2xs);
2656
+ color: var(--text-secondary);
2657
+ border: 1px solid var(--border);
2658
+ background: var(--surface);
2659
+ font-size: var(--text-caption);
2660
+ transition:
2661
+ color var(--duration-fast) ease,
2662
+ border-color var(--duration-fast) ease,
2663
+ background-color var(--duration-fast) ease;
2664
+ }
2665
+
2666
+ .btn:hover {
2667
+ color: var(--text);
2668
+ border-color: var(--accent-border);
2669
+ background: var(--surface-elevated);
2670
+ }
2671
+
2672
+ .btn--icon {
2673
+ width: 34px;
2674
+ height: 34px;
2675
+ padding: 0;
2676
+ justify-content: center;
2677
+ }
2678
+
2679
+ .btn[data-copied='true'] {
2680
+ color: var(--success);
2681
+ border-color: var(--success);
2682
+ }
2683
+
2684
+ .theme-toggle .icon-sun {
2685
+ display: none;
2686
+ }
2687
+
2688
+ [data-theme='light'] .theme-toggle .icon-sun {
2689
+ display: block;
2690
+ }
2691
+
2692
+ [data-theme='light'] .theme-toggle .icon-moon {
2693
+ display: none;
2694
+ }
2695
+
2696
+ /* \u2500\u2500 Shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2697
+ .shell {
2698
+ padding-top: var(--header-height);
2699
+ padding-bottom: 36px;
2700
+ padding-inline: var(--container-pad);
2701
+ height: 100vh;
2702
+ max-width: var(--container);
2703
+ margin-inline: auto;
2704
+ }
2705
+
2706
+ .view {
2707
+ display: none;
2708
+ height: 100%;
2709
+ border-inline: var(--hairline);
2710
+ }
2711
+
2712
+ .view[data-active='true'] {
2713
+ display: grid;
2714
+ }
2715
+
2716
+ .view--skills {
2717
+ grid-template-columns: 340px minmax(0, 1fr);
2718
+ }
2719
+
2720
+ .view--specs {
2721
+ grid-template-columns: 320px minmax(0, 1fr) 232px;
2722
+ }
2723
+
2724
+ .rail {
2725
+ display: flex;
2726
+ flex-direction: column;
2727
+ min-height: 0;
2728
+ border-right: var(--hairline-strong);
2729
+ background: var(--surface);
2730
+ }
2731
+
2732
+ .rail__head {
2733
+ display: grid;
2734
+ gap: var(--space-2xs);
2735
+ padding: var(--space-sm);
2736
+ border-bottom: var(--hairline-strong);
2737
+ }
2738
+
2739
+ .rail__body {
2740
+ overflow-y: auto;
2741
+ padding: var(--space-2xs) 0 var(--space-lg);
2742
+ scrollbar-width: thin;
2743
+ scrollbar-color: var(--border-strong) transparent;
2744
+ }
2745
+
2746
+ .rail__body::-webkit-scrollbar {
2747
+ width: 10px;
2748
+ }
2749
+
2750
+ .rail__body::-webkit-scrollbar-thumb {
2751
+ background: var(--border-strong);
2752
+ border: 3px solid var(--surface);
2753
+ }
2754
+
2755
+ .field {
2756
+ display: flex;
2757
+ align-items: center;
2758
+ gap: var(--space-2xs);
2759
+ padding: 0.375rem var(--space-2xs);
2760
+ background: var(--base);
2761
+ border: 1px solid var(--border);
2762
+ color: var(--text-muted);
2763
+ transition: border-color var(--duration-fast) ease;
2764
+ }
2765
+
2766
+ .field:focus-within {
2767
+ border-color: var(--border-input);
2768
+ }
2769
+
2770
+ .field__input {
2771
+ flex: 1;
2772
+ min-width: 0;
2773
+ border: 0;
2774
+ outline: none;
2775
+ background: none;
2776
+ color: var(--text);
2777
+ font-size: var(--text-small);
2778
+ }
2779
+
2780
+ .field__input::placeholder {
2781
+ color: var(--text-muted);
2782
+ }
2783
+
2784
+ .field kbd {
2785
+ flex: none;
2786
+ padding: 0 0.25rem;
2787
+ border: 1px solid var(--border);
2788
+ color: var(--text-muted);
2789
+ font-family: var(--font-mono);
2790
+ font-size: var(--text-caption);
2791
+ line-height: 1.4;
2792
+ }
2793
+
2794
+ .segmented {
2795
+ display: grid;
2796
+ grid-template-columns: 1fr 1fr;
2797
+ border: 1px solid var(--border);
2798
+ }
2799
+
2800
+ .segmented button {
2801
+ padding: 0.25rem var(--space-2xs);
2802
+ color: var(--text-secondary);
2803
+ background: var(--base);
2804
+ font-family: var(--font-mono);
2805
+ font-size: var(--text-caption);
2806
+ letter-spacing: 0.02em;
2807
+ transition:
2808
+ color var(--duration-fast) ease,
2809
+ background-color var(--duration-fast) ease;
2810
+ }
2811
+
2812
+ .segmented button + button {
2813
+ border-left: 1px solid var(--border);
2814
+ }
2815
+
2816
+ .segmented button:hover {
2817
+ color: var(--text);
2818
+ }
2819
+
2820
+ .segmented button[aria-pressed='true'] {
2821
+ color: var(--accent);
2822
+ background: var(--accent-soft);
2823
+ }
2824
+
2825
+ /* \u2500\u2500 Rail groups and rows \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2826
+ .group {
2827
+ margin-top: var(--space-2xs);
2828
+ }
2829
+
2830
+ .group__head {
2831
+ display: flex;
2832
+ align-items: baseline;
2833
+ gap: var(--space-2xs);
2834
+ width: 100%;
2835
+ padding: var(--space-3xs) var(--space-sm);
2836
+ color: var(--text-muted);
2837
+ font-family: var(--font-mono);
2838
+ font-size: var(--text-caption);
2839
+ letter-spacing: 0.04em;
2840
+ text-transform: uppercase;
2841
+ text-align: left;
2842
+ }
2843
+
2844
+ .group__head:hover {
2845
+ color: var(--text-secondary);
2846
+ }
2847
+
2848
+ .group__count {
2849
+ margin-left: auto;
2850
+ color: var(--text-muted);
2851
+ letter-spacing: 0;
2852
+ }
2853
+
2854
+ .group__path {
2855
+ padding: 0 var(--space-sm) var(--space-3xs);
2856
+ color: var(--text-muted);
2857
+ font-family: var(--font-mono);
2858
+ font-size: var(--text-caption);
2859
+ overflow: hidden;
2860
+ text-overflow: ellipsis;
2861
+ white-space: nowrap;
2862
+ text-align: left;
2863
+ }
2864
+
2865
+ .row {
2866
+ display: grid;
2867
+ gap: 0.125rem;
2868
+ width: 100%;
2869
+ padding: var(--space-3xs) var(--space-sm);
2870
+ border-left: 2px solid transparent;
2871
+ color: var(--text-secondary);
2872
+ text-align: left;
2873
+ transition:
2874
+ color var(--duration-fast) ease,
2875
+ background-color var(--duration-fast) ease,
2876
+ border-color var(--duration-fast) ease;
2877
+ }
2878
+
2879
+ .row:hover {
2880
+ color: var(--text);
2881
+ background: var(--surface-elevated);
2882
+ }
2883
+
2884
+ .row[aria-current='true'] {
2885
+ color: var(--text);
2886
+ background: var(--accent-soft);
2887
+ border-left-color: var(--accent);
2888
+ }
2889
+
2890
+ .row__name {
2891
+ font-family: var(--font-mono);
2892
+ font-size: var(--text-caption);
2893
+ overflow: hidden;
2894
+ text-overflow: ellipsis;
2895
+ white-space: nowrap;
2896
+ }
2897
+
2898
+ .row__meta {
2899
+ color: var(--text-muted);
2900
+ font-family: var(--font-mono);
2901
+ font-size: var(--text-caption);
2902
+ overflow: hidden;
2903
+ text-overflow: ellipsis;
2904
+ white-space: nowrap;
2905
+ }
2906
+
2907
+ .row--dir .row__name {
2908
+ color: var(--text-secondary);
2909
+ }
2910
+
2911
+ .row--file[aria-current='true'] .row__name {
2912
+ color: var(--accent);
2913
+ }
2914
+
2915
+ .row--empty {
2916
+ padding: var(--space-2xs) var(--space-sm);
2917
+ color: var(--text-muted);
2918
+ font-family: var(--font-mono);
2919
+ font-size: var(--text-caption);
2920
+ border-left: 2px dashed var(--border);
2921
+ margin-left: var(--space-sm);
2922
+ }
2923
+
2924
+ .tree {
2925
+ display: grid;
2926
+ }
2927
+
2928
+ .tree__children {
2929
+ padding-left: var(--space-xs);
2930
+ border-left: var(--hairline);
2931
+ margin-left: var(--space-sm);
2932
+ }
2933
+
2934
+ .tree__toggle {
2935
+ display: inline-flex;
2936
+ align-items: center;
2937
+ gap: var(--space-3xs);
2938
+ }
2939
+
2940
+ .tree__chevron {
2941
+ transition: transform var(--duration-fast) ease;
2942
+ color: var(--text-muted);
2943
+ }
2944
+
2945
+ .tree__chevron[data-open='true'] {
2946
+ transform: rotate(90deg);
2947
+ }
2948
+
2949
+ /* \u2500\u2500 Panes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2950
+ .pane {
2951
+ min-width: 0;
2952
+ overflow-y: auto;
2953
+ padding: var(--space-lg) var(--space-xl) var(--space-2xl);
2954
+ scrollbar-width: thin;
2955
+ scrollbar-color: var(--border-strong) transparent;
2956
+ }
2957
+
2958
+ .pane::-webkit-scrollbar {
2959
+ width: 10px;
2960
+ }
2961
+
2962
+ .pane::-webkit-scrollbar-thumb {
2963
+ background: var(--border-strong);
2964
+ border: 3px solid var(--base);
2965
+ }
2966
+
2967
+ .eyebrow {
2968
+ display: inline-flex;
2969
+ align-items: center;
2970
+ gap: var(--space-3xs);
2971
+ margin-bottom: var(--space-2xs);
2972
+ padding: 0.125rem var(--space-2xs);
2973
+ background: var(--accent-soft);
2974
+ border: 1px solid var(--accent-border);
2975
+ color: var(--accent);
2976
+ font-family: var(--font-mono);
2977
+ font-size: var(--text-caption);
2978
+ letter-spacing: 0.04em;
2979
+ text-transform: uppercase;
2980
+ }
2981
+
2982
+ .detail__title {
2983
+ font-family: var(--font-mono);
2984
+ font-size: clamp(1.5rem, 2.6vw, var(--text-h2));
2985
+ font-weight: 600;
2986
+ line-height: var(--leading-h2);
2987
+ letter-spacing: -0.02em;
2988
+ overflow-wrap: anywhere;
2989
+ }
2990
+
2991
+ .detail__lede {
2992
+ max-width: 68ch;
2993
+ margin-top: var(--space-2xs);
2994
+ color: var(--text-secondary);
2995
+ font-size: var(--text-body);
2996
+ }
2997
+
2998
+ .detail__actions {
2999
+ display: flex;
3000
+ flex-wrap: wrap;
3001
+ gap: var(--space-2xs);
3002
+ margin-top: var(--space-sm);
3003
+ }
3004
+
3005
+ .meta {
3006
+ display: grid;
3007
+ margin-top: var(--space-lg);
3008
+ border-top: var(--hairline-strong);
3009
+ }
3010
+
3011
+ .meta__row {
3012
+ display: grid;
3013
+ grid-template-columns: 132px minmax(0, 1fr);
3014
+ gap: var(--space-sm);
3015
+ align-items: baseline;
3016
+ padding: var(--space-2xs) 0;
3017
+ border-bottom: var(--hairline);
3018
+ }
3019
+
3020
+ .meta__key {
3021
+ color: var(--text-muted);
3022
+ font-family: var(--font-mono);
3023
+ font-size: var(--text-caption);
3024
+ letter-spacing: 0.04em;
3025
+ text-transform: uppercase;
3026
+ }
3027
+
3028
+ .meta__value {
3029
+ min-width: 0;
3030
+ color: var(--text-secondary);
3031
+ font-family: var(--font-mono);
3032
+ font-size: var(--text-caption);
3033
+ overflow-wrap: anywhere;
3034
+ }
3035
+
3036
+ .meta__value--strong {
3037
+ color: var(--text);
3038
+ }
3039
+
3040
+ /* \u2500\u2500 Document \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3041
+ .doc-head {
3042
+ max-width: var(--text-column);
3043
+ padding-bottom: var(--space-sm);
3044
+ border-bottom: var(--hairline-strong);
3045
+ margin-bottom: var(--space-md);
3046
+ }
3047
+
3048
+ .doc-head__path {
3049
+ color: var(--text-muted);
3050
+ font-family: var(--font-mono);
3051
+ font-size: var(--text-caption);
3052
+ overflow-wrap: anywhere;
3053
+ }
3054
+
3055
+ .doc-head__title {
3056
+ margin-top: var(--space-3xs);
3057
+ font-size: clamp(1.5rem, 2.6vw, var(--text-h2));
3058
+ font-weight: 600;
3059
+ line-height: var(--leading-h2);
3060
+ letter-spacing: -0.02em;
3061
+ }
3062
+
3063
+ .doc-head__stats {
3064
+ display: flex;
3065
+ flex-wrap: wrap;
3066
+ gap: var(--space-2xs) var(--space-xs);
3067
+ margin-top: var(--space-2xs);
3068
+ color: var(--text-muted);
3069
+ font-family: var(--font-mono);
3070
+ font-size: var(--text-caption);
3071
+ }
3072
+
3073
+ .doc {
3074
+ max-width: var(--text-column);
3075
+ color: var(--text-secondary);
3076
+ font-size: var(--text-body);
3077
+ line-height: var(--leading-body);
3078
+ }
3079
+
3080
+ .doc > * + * {
3081
+ margin-top: var(--space-sm);
3082
+ }
3083
+
3084
+ .doc h1,
3085
+ .doc h2,
3086
+ .doc h3,
3087
+ .doc h4,
3088
+ .doc h5,
3089
+ .doc h6 {
3090
+ color: var(--text);
3091
+ font-weight: 600;
3092
+ scroll-margin-top: calc(var(--header-height) + var(--space-md));
3093
+ text-wrap: balance;
3094
+ }
3095
+
3096
+ .doc h1 {
3097
+ margin-top: var(--space-lg);
3098
+ font-size: var(--text-h3);
3099
+ line-height: var(--leading-h3);
3100
+ letter-spacing: -0.015em;
3101
+ }
3102
+
3103
+ .doc h2 {
3104
+ margin-top: var(--space-lg);
3105
+ padding-top: var(--space-2xs);
3106
+ border-top: var(--hairline);
3107
+ font-size: var(--text-h4);
3108
+ line-height: var(--leading-h3);
3109
+ }
3110
+
3111
+ .doc h3 {
3112
+ margin-top: var(--space-md);
3113
+ font-size: var(--text-body);
3114
+ color: var(--text);
3115
+ }
3116
+
3117
+ .doc h4,
3118
+ .doc h5,
3119
+ .doc h6 {
3120
+ margin-top: var(--space-sm);
3121
+ font-size: var(--text-small);
3122
+ color: var(--text-secondary);
3123
+ }
3124
+
3125
+ .doc strong {
3126
+ color: var(--text);
3127
+ font-weight: 600;
3128
+ }
3129
+
3130
+ .doc em {
3131
+ font-style: italic;
3132
+ }
3133
+
3134
+ .doc del {
3135
+ color: var(--text-muted);
3136
+ }
3137
+
3138
+ .doc a {
3139
+ text-decoration-style: dashed;
3140
+ }
3141
+
3142
+ .doc ul,
3143
+ .doc ol {
3144
+ padding-left: 1.35em;
3145
+ }
3146
+
3147
+ .doc li + li {
3148
+ margin-top: 0.25em;
3149
+ }
3150
+
3151
+ .doc li > ul,
3152
+ .doc li > ol {
3153
+ margin-top: 0.25em;
3154
+ }
3155
+
3156
+ .doc li::marker {
3157
+ color: var(--text-muted);
3158
+ font-family: var(--font-mono);
3159
+ font-size: var(--text-caption);
3160
+ }
3161
+
3162
+ /* Las listas de tareas ya traen su propio control: el bullet sobra. */
3163
+ .doc li:has(> input[type='checkbox']) {
3164
+ list-style: none;
3165
+ margin-left: -1.15em;
3166
+ }
3167
+
3168
+ .doc input[type='checkbox'] {
3169
+ appearance: none;
3170
+ width: 13px;
3171
+ height: 13px;
3172
+ margin-right: 0.5em;
3173
+ border: 1px solid var(--border-input);
3174
+ vertical-align: -1px;
3175
+ }
3176
+
3177
+ .doc input[type='checkbox'][checked] {
3178
+ background: var(--accent);
3179
+ border-color: var(--accent);
3180
+ }
3181
+
3182
+ .doc blockquote {
3183
+ padding: var(--space-2xs) var(--space-sm);
3184
+ border-left: 2px solid var(--accent-border);
3185
+ background: var(--surface);
3186
+ color: var(--text-secondary);
3187
+ }
3188
+
3189
+ .doc blockquote p + p {
3190
+ margin-top: var(--space-2xs);
3191
+ }
3192
+
3193
+ .doc hr {
3194
+ height: 0;
3195
+ border: 0;
3196
+ border-top: var(--hairline-strong);
3197
+ }
3198
+
3199
+ .doc code {
3200
+ padding: 0.125em 0.375em;
3201
+ background: var(--accent-soft);
3202
+ color: var(--accent);
3203
+ font-family: var(--font-mono);
3204
+ font-size: 0.86em;
3205
+ }
3206
+
3207
+ .doc pre {
3208
+ overflow-x: auto;
3209
+ padding: var(--space-xs) var(--space-sm);
3210
+ background: var(--code-bg);
3211
+ border: 1px solid var(--border);
3212
+ scrollbar-width: thin;
3213
+ }
3214
+
3215
+ .doc pre code {
3216
+ padding: 0;
3217
+ background: none;
3218
+ color: var(--syn-variable);
3219
+ font-size: var(--text-code);
3220
+ line-height: 1.55;
3221
+ }
3222
+
3223
+ .doc table {
3224
+ width: 100%;
3225
+ border-collapse: collapse;
3226
+ font-size: var(--text-small);
3227
+ }
3228
+
3229
+ .doc th,
3230
+ .doc td {
3231
+ padding: var(--space-3xs) var(--space-2xs);
3232
+ border: 1px solid var(--border);
3233
+ text-align: left;
3234
+ vertical-align: top;
3235
+ }
3236
+
3237
+ .doc th {
3238
+ background: var(--surface);
3239
+ color: var(--text);
3240
+ font-family: var(--font-mono);
3241
+ font-size: var(--text-caption);
3242
+ letter-spacing: 0.02em;
3243
+ font-weight: 500;
3244
+ }
3245
+
3246
+ .doc td {
3247
+ font-family: var(--font-mono);
3248
+ font-size: var(--text-caption);
3249
+ }
3250
+
3251
+ .doc .md-img {
3252
+ display: inline-flex;
3253
+ padding: 0 var(--space-3xs);
3254
+ border: 1px dashed var(--border-strong);
3255
+ color: var(--text-muted);
3256
+ font-family: var(--font-mono);
3257
+ font-size: var(--text-caption);
3258
+ }
3259
+
3260
+ /* \u2500\u2500 Table of contents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3261
+ .toc {
3262
+ overflow-y: auto;
3263
+ padding: var(--space-lg) var(--space-sm);
3264
+ border-left: var(--hairline-strong);
3265
+ background: var(--surface);
3266
+ scrollbar-width: thin;
3267
+ scrollbar-color: var(--border-strong) transparent;
3268
+ }
3269
+
3270
+ .toc__title {
3271
+ color: var(--text-muted);
3272
+ font-family: var(--font-mono);
3273
+ font-size: var(--text-caption);
3274
+ letter-spacing: 0.04em;
3275
+ text-transform: uppercase;
3276
+ }
3277
+
3278
+ .toc__list {
3279
+ display: grid;
3280
+ gap: 0.125rem;
3281
+ margin-top: var(--space-2xs);
3282
+ }
3283
+
3284
+ .toc__link {
3285
+ display: block;
3286
+ padding: 0.125rem var(--space-2xs);
3287
+ border-left: 2px solid transparent;
3288
+ color: var(--text-secondary);
3289
+ font-size: var(--text-caption);
3290
+ text-decoration: none;
3291
+ overflow: hidden;
3292
+ text-overflow: ellipsis;
3293
+ white-space: nowrap;
3294
+ transition:
3295
+ color var(--duration-fast) ease,
3296
+ border-color var(--duration-fast) ease;
3297
+ }
3298
+
3299
+ .toc__link:hover {
3300
+ color: var(--text);
3301
+ border-left-color: var(--border-strong);
3302
+ }
3303
+
3304
+ .toc__link[data-level='3'] {
3305
+ padding-left: var(--space-sm);
3306
+ color: var(--text-muted);
3307
+ }
3308
+
3309
+ .toc__link[data-level='4'],
3310
+ .toc__link[data-level='5'],
3311
+ .toc__link[data-level='6'] {
3312
+ display: none;
3313
+ }
3314
+
3315
+ /* \u2500\u2500 States \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3316
+ .empty {
3317
+ display: grid;
3318
+ gap: var(--space-2xs);
3319
+ max-width: 560px;
3320
+ margin: var(--space-2xl) auto;
3321
+ padding: var(--space-lg);
3322
+ border: var(--hairline-strong);
3323
+ background: var(--surface);
3324
+ text-align: center;
3325
+ }
3326
+
3327
+ .empty__title {
3328
+ color: var(--text);
3329
+ font-size: var(--text-h4);
3330
+ font-weight: 600;
3331
+ letter-spacing: -0.01em;
3332
+ }
3333
+
3334
+ .empty__text {
3335
+ color: var(--text-secondary);
3336
+ font-size: var(--text-small);
3337
+ }
3338
+
3339
+ .empty__path {
3340
+ margin-top: var(--space-2xs);
3341
+ padding: var(--space-2xs);
3342
+ background: var(--base);
3343
+ border: 1px solid var(--border);
3344
+ color: var(--text-muted);
3345
+ font-family: var(--font-mono);
3346
+ font-size: var(--text-caption);
3347
+ overflow-wrap: anywhere;
3348
+ }
3349
+
3350
+ .empty__hint {
3351
+ color: var(--text-muted);
3352
+ font-family: var(--font-mono);
3353
+ font-size: var(--text-caption);
3354
+ }
3355
+
3356
+ .loading {
3357
+ padding: var(--space-lg) var(--space-sm);
3358
+ color: var(--text-muted);
3359
+ font-family: var(--font-mono);
3360
+ font-size: var(--text-caption);
3361
+ animation: pulse 1.6s ease-in-out infinite;
3362
+ }
3363
+
3364
+ @keyframes pulse {
3365
+ 0%,
3366
+ 100% {
3367
+ opacity: 0.45;
3368
+ }
3369
+ 50% {
3370
+ opacity: 1;
3371
+ }
3372
+ }
3373
+
3374
+ /* \u2500\u2500 Status bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3375
+ .statusbar {
3376
+ position: fixed;
3377
+ inset: auto 0 0 0;
3378
+ z-index: 40;
3379
+ display: flex;
3380
+ align-items: center;
3381
+ gap: var(--space-sm);
3382
+ height: 36px;
3383
+ padding-inline: var(--container-pad);
3384
+ background: var(--base);
3385
+ border-top: var(--hairline-strong);
3386
+ color: var(--text-muted);
3387
+ font-family: var(--font-mono);
3388
+ font-size: var(--text-caption);
3389
+ }
3390
+
3391
+ .statusbar__item {
3392
+ overflow: hidden;
3393
+ text-overflow: ellipsis;
3394
+ white-space: nowrap;
3395
+ }
3396
+
3397
+ .statusbar__spacer {
3398
+ flex: 1;
3399
+ }
3400
+
3401
+ .statusbar__dot {
3402
+ color: var(--success);
3403
+ animation: pulse 2.4s ease-in-out infinite;
3404
+ }
3405
+
3406
+ .statusbar kbd {
3407
+ padding: 0 0.25rem;
3408
+ border: 1px solid var(--border);
3409
+ color: var(--text-secondary);
3410
+ }
3411
+
3412
+ /* \u2500\u2500 Responsive: the landing page collapses the same way \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3413
+ @media (max-width: 1100px) {
3414
+ .view--specs {
3415
+ grid-template-columns: 280px minmax(0, 1fr);
3416
+ }
3417
+
3418
+ .toc {
3419
+ display: none;
3420
+ }
3421
+ }
3422
+
3423
+ @media (max-width: 820px) {
3424
+ .shell {
3425
+ padding-inline: 0;
3426
+ }
3427
+
3428
+ .topbar__inner,
3429
+ .statusbar {
3430
+ padding-inline: var(--space-sm);
3431
+ }
3432
+
3433
+ .brand__div,
3434
+ .brand__page {
3435
+ display: none;
3436
+ }
3437
+
3438
+ .view--skills,
3439
+ .view--specs {
3440
+ grid-template-columns: minmax(0, 1fr);
3441
+ }
3442
+
3443
+ .rail {
3444
+ border-right: 0;
3445
+ border-bottom: var(--hairline-strong);
3446
+ max-height: 42vh;
3447
+ }
3448
+
3449
+ .pane {
3450
+ padding: var(--space-md) var(--space-sm) var(--space-xl);
3451
+ }
3452
+
3453
+ .statusbar__hint {
3454
+ display: none;
3455
+ }
3456
+ }
3457
+
3458
+ @media (prefers-reduced-motion: reduce) {
3459
+ *,
3460
+ *::before,
3461
+ *::after {
3462
+ animation-duration: 0.01ms !important;
3463
+ animation-iteration-count: 1 !important;
3464
+ transition-duration: 0.01ms !important;
3465
+ }
3466
+ }
3467
+ `;
3468
+
3469
+ // src/studio/ui/tokens.ts
3470
+ var TOKENS_CSS = `
3471
+ :root {
3472
+ color-scheme: dark;
3473
+
3474
+ /* Surfaces */
3475
+ --base: #07090f;
3476
+ --surface: #0b0e16;
3477
+ --surface-elevated: #121623;
3478
+ --code-bg: #121623;
3479
+
3480
+ /* Text: AA on all three surfaces */
3481
+ --text: #eef2f8;
3482
+ --text-secondary: #a8b2c4;
3483
+ --text-muted: #77839a;
3484
+
3485
+ /* Electric blue accent scale */
3486
+ --accent: #8aa2ff;
3487
+ --accent-hover: #a6b8ff;
3488
+ --accent-active: #6f8ae6;
3489
+ --accent-soft: #16203a;
3490
+ --accent-soft-hover: #1d2a4d;
3491
+ --accent-border: #3d4a7a;
3492
+
3493
+ /* Semantics */
3494
+ --success: #4ade80;
3495
+ --warning: #fbbf24;
3496
+ --error: #f87171;
3497
+ --on-accent: #07090f;
3498
+
3499
+ /* Borders */
3500
+ --border: #1e2433;
3501
+ --border-strong: #2c3446;
3502
+ --border-input: #5f6b8a;
3503
+
3504
+ /* Structural lines are dashed by design; control borders stay solid */
3505
+ --hairline: 1px dashed var(--border);
3506
+ --hairline-strong: 1px dashed var(--border-strong);
3507
+
3508
+ /* Focus */
3509
+ --focus-ring: var(--accent);
3510
+ --focus-ring-offset: var(--base);
3511
+
3512
+ /* Only two shadows exist */
3513
+ --shadow-pop: 0 8px 24px rgb(0 0 0 / 0.28);
3514
+ --shadow-header: 0 1px 0 var(--border);
3515
+
3516
+ /* Scrim */
3517
+ --scrim: rgb(3 6 12 / 0.6);
3518
+
3519
+ /* Typography */
3520
+ --font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI',
3521
+ Roboto, sans-serif;
3522
+ --font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace,
3523
+ SFMono-Regular, Menlo, Consolas, monospace;
3524
+
3525
+ --text-display: 3.75rem;
3526
+ --text-h1: 2.75rem;
3527
+ --text-h2: 2.125rem;
3528
+ --text-h3: 1.5rem;
3529
+ --text-h4: 1.1875rem;
3530
+ --text-body: 1.0625rem;
3531
+ --text-small: 0.9375rem;
3532
+ --text-caption: 0.8125rem;
3533
+ --text-code: 0.875rem;
3534
+
3535
+ --leading-display: 1.1;
3536
+ --leading-h1: 1.15;
3537
+ --leading-h2: 1.2;
3538
+ --leading-h3: 1.3;
3539
+ --leading-body: 1.6;
3540
+
3541
+ /* Spacing */
3542
+ --space-3xs: 0.25rem;
3543
+ --space-2xs: 0.5rem;
3544
+ --space-xs: 0.75rem;
3545
+ --space-sm: 1rem;
3546
+ --space-md: 1.5rem;
3547
+ --space-lg: 2rem;
3548
+ --space-xl: 3rem;
3549
+ --space-2xl: 4rem;
3550
+ --space-3xl: 6rem;
3551
+ --space-4xl: 7.5rem;
3552
+
3553
+ /* Radii: square corners everywhere, roles kept for a one-line rollback */
3554
+ --radius-sm: 0px;
3555
+ --radius-md: 0px;
3556
+ --radius-pill: 0px;
3557
+
3558
+ /* Layout */
3559
+ --container: 1300px;
3560
+ --container-pad: 48px;
3561
+ --text-column: 800px;
3562
+ --hero-column: 560px;
3563
+ --header-height: 72px;
3564
+
3565
+ /* Motion */
3566
+ --duration-fast: 150ms;
3567
+ --duration-base: 250ms;
3568
+ --duration-reveal: 400ms;
3569
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
3570
+
3571
+ /* Syntax, >= 4.5 on --code-bg in both themes */
3572
+ --syn-comment: #77839a;
3573
+ --syn-keyword: #a78bfa;
3574
+ --syn-string: #86efac;
3575
+ --syn-number: #fbbf24;
3576
+ --syn-function: #7dd3fc;
3577
+ --syn-type: #f0abfc;
3578
+ --syn-variable: #e6edf7;
3579
+ --syn-tag: #f87171;
3580
+ --syn-operator: #9aa5b8;
3581
+ --syn-punctuation: #8b95a8;
3582
+ }
3583
+
3584
+ /* Light theme: same roles, measured values. Both blocks are the same theme:
3585
+ one answers the system preference when the user has not chosen, the other
3586
+ answers the stored choice. Keep them identical. */
3587
+ [data-theme='light'] {
3588
+ color-scheme: light;
3589
+
3590
+ --base: #ffffff;
3591
+ --surface: #f7f8fb;
3592
+ --surface-elevated: #eef1f7;
3593
+ --code-bg: #eef1f7;
3594
+
3595
+ --text: #0d1220;
3596
+ --text-secondary: #454f63;
3597
+ --text-muted: #5c6678;
3598
+
3599
+ --accent: #3d54d4;
3600
+ --accent-hover: #2b3ea8;
3601
+ --accent-active: #24337f;
3602
+ --accent-soft: #eaeeff;
3603
+ --accent-soft-hover: #dde3ff;
3604
+ --accent-border: #b9c2ee;
3605
+
3606
+ --success: #166534;
3607
+ --warning: #78350f;
3608
+ --error: #b91c1c;
3609
+ --on-accent: #ffffff;
3610
+
3611
+ --border: #dfe3ec;
3612
+ --border-strong: #c3cad8;
3613
+ --border-input: #828b9c;
3614
+
3615
+ --shadow-pop: 0 8px 24px rgb(13 18 32 / 0.1);
3616
+ --scrim: rgb(13 18 32 / 0.5);
3617
+
3618
+ --syn-comment: #5c6678;
3619
+ --syn-keyword: #7c3aed;
3620
+ --syn-string: #166534;
3621
+ --syn-number: #92400e;
3622
+ --syn-function: #0e7490;
3623
+ --syn-type: #a21caf;
3624
+ --syn-variable: #0d1220;
3625
+ --syn-tag: #b91c1c;
3626
+ --syn-operator: #5c6678;
3627
+ --syn-punctuation: #5c6678;
3628
+ }
3629
+
3630
+ @media (prefers-color-scheme: light) {
3631
+ :root:not([data-theme]) {
3632
+ color-scheme: light;
3633
+
3634
+ --base: #ffffff;
3635
+ --surface: #f7f8fb;
3636
+ --surface-elevated: #eef1f7;
3637
+ --code-bg: #eef1f7;
3638
+
3639
+ --text: #0d1220;
3640
+ --text-secondary: #454f63;
3641
+ --text-muted: #5c6678;
3642
+
3643
+ --accent: #3d54d4;
3644
+ --accent-hover: #2b3ea8;
3645
+ --accent-active: #24337f;
3646
+ --accent-soft: #eaeeff;
3647
+ --accent-soft-hover: #dde3ff;
3648
+ --accent-border: #b9c2ee;
3649
+
3650
+ --success: #166534;
3651
+ --warning: #78350f;
3652
+ --error: #b91c1c;
3653
+ --on-accent: #ffffff;
3654
+
3655
+ --border: #dfe3ec;
3656
+ --border-strong: #c3cad8;
3657
+ --border-input: #828b9c;
3658
+
3659
+ --shadow-pop: 0 8px 24px rgb(13 18 32 / 0.1);
3660
+ --scrim: rgb(13 18 32 / 0.5);
3661
+
3662
+ --syn-comment: #5c6678;
3663
+ --syn-keyword: #7c3aed;
3664
+ --syn-string: #166534;
3665
+ --syn-number: #92400e;
3666
+ --syn-function: #0e7490;
3667
+ --syn-type: #a21caf;
3668
+ --syn-variable: #0d1220;
3669
+ --syn-tag: #b91c1c;
3670
+ --syn-operator: #5c6678;
3671
+ --syn-punctuation: #5c6678;
3672
+ }
3673
+ }
3674
+ `;
3675
+
3676
+ // src/studio/ui/document.ts
3677
+ var THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem('fwinit:theme');if(t==='light'||t==='dark'){document.documentElement.dataset.theme=t;}}catch(e){}})();`;
3678
+ var ICON = {
3679
+ search: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/></svg>',
3680
+ moon: '<svg class="icon-moon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M13.2 9.4A5.6 5.6 0 0 1 6.6 2.8a5.6 5.6 0 1 0 6.6 6.6Z"/></svg>',
3681
+ sun: '<svg class="icon-sun" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><circle cx="8" cy="8" r="3.2"/><path d="M8 1v1.6M8 13.4V15M1 8h1.6M13.4 8H15M3.05 3.05l1.13 1.13M11.82 11.82l1.13 1.13M12.95 3.05l-1.13 1.13M4.18 11.82l-1.13 1.13"/></svg>'
3682
+ };
3683
+ function escapeHtml(value) {
3684
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3685
+ }
3686
+ var FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2316203a'/%3E%3Crect x='0.5' y='0.5' width='31' height='31' fill='none' stroke='%238aa2ff'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' font-weight='700' fill='%238aa2ff' text-anchor='middle'%3ES%3C/text%3E%3C/svg%3E";
3687
+ function renderStudioHtml(options) {
3688
+ const meta = JSON.stringify({
3689
+ version: options.version,
3690
+ projectRoot: options.projectRoot,
3691
+ home: options.home ?? ""
3692
+ }).replace(/</g, "\\u003c");
3693
+ return `<!doctype html>
3694
+ <html lang="es">
3695
+ <head>
3696
+ <meta charset="utf-8" />
3697
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3698
+ <meta name="color-scheme" content="dark light" />
3699
+ <meta name="robots" content="noindex, nofollow" />
3700
+ <title>fwinit studio</title>
3701
+ <meta name="description" content="Vista local de las skills instaladas para opencode, pi y Gentle Shell, y de las specs de este proyecto." />
3702
+ <link rel="icon" href="${FAVICON}" />
3703
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
3704
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
3705
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..700;1,14..32,400..600&family=JetBrains+Mono:wght@400;500;600&display=swap" />
3706
+ <script>${THEME_BOOTSTRAP}</script>
3707
+ <style>${TOKENS_CSS}</style>
3708
+ <style>${STUDIO_CSS}</style>
3709
+ </head>
3710
+ <body>
3711
+ <a class="skip" href="#main">Saltar al contenido</a>
3712
+ <header class="topbar">
3713
+ <div class="topbar__inner">
3714
+ <a class="brand" href="#/skills" aria-label="fwinit studio, inicio">
3715
+ <span class="brand__glyph" aria-hidden="true">S</span>
3716
+ <span class="brand__name">fwinit</span>
3717
+ <span class="brand__div" aria-hidden="true">/</span>
3718
+ <span class="brand__page">studio</span>
3719
+ </a>
3720
+ <nav class="tabs" aria-label="Secciones">
3721
+ <button type="button" class="tab" role="tab" data-view="skills" aria-selected="true">
3722
+ Skills <span class="tab__count" data-count="skills">\u2014</span>
3723
+ </button>
3724
+ <button type="button" class="tab" role="tab" data-view="specs" aria-selected="false">
3725
+ Specs <span class="tab__count" data-count="specs">\u2014</span>
3726
+ </button>
3727
+ </nav>
3728
+ <div class="topbar__actions">
3729
+ <span class="badge badge--version">v${escapeHtml(options.version)}</span>
3730
+ <button type="button" class="btn btn--icon theme-toggle" data-theme-toggle aria-pressed="false" aria-label="Cambiar al tema claro">
3731
+ ${ICON.moon}${ICON.sun}
3732
+ </button>
3733
+ </div>
3734
+ </div>
3735
+ </header>
3736
+
3737
+ <main id="main" class="shell">
3738
+ <section class="view view--skills" data-view-panel="skills" data-active="true" aria-label="Skills instaladas">
3739
+ <aside class="rail">
3740
+ <div class="rail__head">
3741
+ <div class="field">
3742
+ ${ICON.search}
3743
+ <input class="field__input" type="search" data-search="skills" placeholder="Filtrar skills" aria-label="Filtrar skills" autocomplete="off" spellcheck="false" />
3744
+ <kbd>/</kbd>
3745
+ </div>
3746
+ <div class="segmented" role="group" aria-label="Alcance">
3747
+ <button type="button" data-scope="global" aria-pressed="true">Globales</button>
3748
+ <button type="button" data-scope="project" aria-pressed="false">Proyecto</button>
3749
+ </div>
3750
+ </div>
3751
+ <div class="rail__body" data-list="skills">
3752
+ <p class="loading">Leyendo las skills instaladas\u2026</p>
3753
+ </div>
3754
+ </aside>
3755
+ <section class="pane" data-detail="skills">
3756
+ <p class="loading">Leyendo las skills instaladas\u2026</p>
3757
+ </section>
3758
+ </section>
3759
+
3760
+ <section class="view view--specs" data-view-panel="specs" data-active="false" aria-label="Specs del proyecto">
3761
+ <aside class="rail">
3762
+ <div class="rail__head">
3763
+ <div class="field">
3764
+ ${ICON.search}
3765
+ <input class="field__input" type="search" data-search="specs" placeholder="Filtrar specs" aria-label="Filtrar specs" autocomplete="off" spellcheck="false" />
3766
+ <kbd>/</kbd>
3767
+ </div>
3768
+ </div>
3769
+ <div class="rail__body" data-list="specs">
3770
+ <p class="loading">Buscando specs/\u2026</p>
3771
+ </div>
3772
+ </aside>
3773
+ <section class="pane" data-detail="specs">
3774
+ <p class="loading">Buscando specs/\u2026</p>
3775
+ </section>
3776
+ <aside class="toc" data-toc aria-label="En esta p\xE1gina">
3777
+ <p class="toc__title">En esta p\xE1gina</p>
3778
+ <div class="toc__list" data-toc-list></div>
3779
+ </aside>
3780
+ </section>
3781
+ </main>
3782
+
3783
+ <footer class="statusbar">
3784
+ <span class="statusbar__dot" aria-hidden="true">\u25CF</span>
3785
+ <span class="statusbar__item" data-status-root title="">${escapeHtml(options.projectRoot)}</span>
3786
+ <span class="statusbar__spacer"></span>
3787
+ <span class="statusbar__item" data-status-counts></span>
3788
+ <span class="statusbar__item statusbar__hint"><kbd>/</kbd> filtrar \xB7 <kbd>1</kbd><kbd>2</kbd> vista \xB7 <kbd>t</kbd> tema</span>
3789
+ </footer>
3790
+
3791
+ <script type="application/json" id="studio-meta">${meta}</script>
3792
+ <script>${CLIENT_JS}</script>
3793
+ </body>
3794
+ </html>
3795
+ `;
3796
+ }
3797
+
3798
+ // src/studio/open-browser.ts
3799
+ import { spawn, spawnSync } from "child_process";
3800
+ function openBrowser(url) {
3801
+ const platform = process.platform;
3802
+ if (platform === "darwin") {
3803
+ return trySpawn("open", [url]);
3804
+ }
3805
+ if (platform === "win32") {
3806
+ const result = spawnSync("cmd", ["/c", "start", '""', url], {
3807
+ stdio: "ignore",
3808
+ windowsHide: true
3809
+ });
3810
+ return !result.error && result.status === 0;
3811
+ }
3812
+ for (const command of ["xdg-open", "gio", "sensible-browser", "x-www-browser"]) {
3813
+ const args = command === "gio" ? ["open", url] : [url];
3814
+ if (trySpawn(command, args)) return true;
3815
+ }
3816
+ const configured = process.env.BROWSER?.trim();
3817
+ if (configured) return trySpawn(configured, [url]);
3818
+ return false;
3819
+ }
3820
+ function trySpawn(command, args) {
3821
+ try {
3822
+ const child = spawn(command, args, {
3823
+ stdio: "ignore",
3824
+ detached: true,
3825
+ windowsHide: true
3826
+ });
3827
+ child.on("error", () => {
3828
+ });
3829
+ child.unref();
3830
+ return true;
3831
+ } catch {
3832
+ return false;
3833
+ }
3834
+ }
3835
+
3836
+ // src/studio/server.ts
3837
+ var HOST = "127.0.0.1";
3838
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
3839
+ function isAllowedHost(header, port) {
3840
+ if (typeof header !== "string") return false;
3841
+ const value = header.trim().toLowerCase();
3842
+ if (value === "") return false;
3843
+ if (value.startsWith("[")) {
3844
+ const close = value.indexOf("]");
3845
+ if (close === -1) return false;
3846
+ const hostname2 = value.slice(0, close + 1);
3847
+ const rest = value.slice(close + 1);
3848
+ if (!LOOPBACK_HOSTNAMES.has(hostname2)) return false;
3849
+ if (rest === "") return true;
3850
+ return rest.startsWith(":") && rest.slice(1) !== "" && rest.slice(1) === String(port);
3851
+ }
3852
+ const colon = value.lastIndexOf(":");
3853
+ const hostname = colon === -1 ? value : value.slice(0, colon);
3854
+ if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
3855
+ if (colon === -1) return true;
3856
+ const requestedPort = value.slice(colon + 1);
3857
+ return requestedPort !== "" && requestedPort === String(port);
3858
+ }
3859
+ var SECURITY_HEADERS = {
3860
+ "X-Content-Type-Options": "nosniff",
3861
+ "X-Frame-Options": "DENY",
3862
+ "Referrer-Policy": "no-referrer",
3863
+ "Cross-Origin-Opener-Policy": "same-origin",
3864
+ // Everything is inline by design: one document, one stylesheet, one script.
3865
+ // Only the font files are remote, and only from Google Fonts.
3866
+ "Content-Security-Policy": [
3867
+ "default-src 'none'",
3868
+ "base-uri 'none'",
3869
+ "form-action 'none'",
3870
+ "frame-ancestors 'none'",
3871
+ "img-src data:",
3872
+ "style-src 'unsafe-inline' https://fonts.googleapis.com",
3873
+ "font-src https://fonts.gstatic.com",
3874
+ "script-src 'unsafe-inline'",
3875
+ "connect-src 'self'"
3876
+ ].join("; ")
3877
+ };
3878
+ var SPEC_ERROR_STATUS = {
3879
+ invalid: 400,
3880
+ outside: 403,
3881
+ missing: 404,
3882
+ unsupported: 415
3883
+ };
3884
+ function sendJson(response, status, body) {
3885
+ const payload = JSON.stringify(body);
3886
+ response.writeHead(status, {
3887
+ ...SECURITY_HEADERS,
3888
+ "Content-Type": "application/json; charset=utf-8",
3889
+ "Content-Length": Buffer.byteLength(payload),
3890
+ "Cache-Control": "no-store"
3891
+ });
3892
+ response.end(payload);
3893
+ }
3894
+ function sendHtml(response, html) {
3895
+ response.writeHead(200, {
3896
+ ...SECURITY_HEADERS,
3897
+ "Content-Type": "text/html; charset=utf-8",
3898
+ "Content-Length": Buffer.byteLength(html),
3899
+ "Cache-Control": "no-store"
3900
+ });
3901
+ response.end(html);
3902
+ }
3903
+ function sendText(response, status, text) {
3904
+ response.writeHead(status, {
3905
+ ...SECURITY_HEADERS,
3906
+ "Content-Type": "text/plain; charset=utf-8",
3907
+ "Content-Length": Buffer.byteLength(text),
3908
+ "Cache-Control": "no-store"
3909
+ });
3910
+ response.end(text);
3911
+ }
3912
+ async function startStudio(options = {}) {
3913
+ const projectRoot = resolve3(options.projectRoot ?? process.cwd());
3914
+ const version = options.version ?? "0.0.0";
3915
+ const home = options.home;
3916
+ let listeningPort = options.port ?? 0;
3917
+ const server = createServer((request, response) => {
3918
+ handle(request, response).catch((error) => {
3919
+ if (response.headersSent) {
3920
+ response.end();
3921
+ return;
3922
+ }
3923
+ const message = error instanceof Error ? error.message : String(error);
3924
+ process.stderr.write(`fwinit studio: ${message}
3925
+ `);
3926
+ sendJson(response, 500, { error: "No se pudo completar la solicitud." });
3927
+ });
3928
+ });
3929
+ async function handle(request, response) {
3930
+ const method = request.method ?? "GET";
3931
+ if (method !== "GET" && method !== "HEAD") {
3932
+ response.setHeader("Allow", "GET, HEAD");
3933
+ sendJson(response, 405, { error: "M\xE9todo no permitido." });
3934
+ return;
3935
+ }
3936
+ const hostPort = listeningPort || request.socket.localPort || 0;
3937
+ if (!isAllowedHost(request.headers.host, hostPort)) {
3938
+ sendJson(response, 403, { error: "Host no permitido." });
3939
+ return;
3940
+ }
3941
+ const url2 = new URL(request.url ?? "/", `http://${HOST}`);
3942
+ let pathname;
3943
+ try {
3944
+ pathname = decodeURIComponent(url2.pathname);
3945
+ } catch {
3946
+ sendJson(response, 400, { error: "Ruta inv\xE1lida." });
3947
+ return;
3948
+ }
3949
+ if (pathname === "/" || pathname === "/index.html") {
3950
+ sendHtml(
3951
+ response,
3952
+ renderStudioHtml({
3953
+ version,
3954
+ projectRoot,
3955
+ home: home ?? process.env.HOME ?? ""
3956
+ })
3957
+ );
3958
+ return;
3959
+ }
3960
+ if (pathname === "/health") {
3961
+ sendJson(response, 200, { ok: true, projectRoot });
3962
+ return;
3963
+ }
3964
+ if (pathname === "/api/meta") {
3965
+ sendJson(response, 200, {
3966
+ version,
3967
+ projectRoot,
3968
+ home: home ?? process.env.HOME ?? "",
3969
+ platform: process.platform
3970
+ });
3971
+ return;
3972
+ }
3973
+ if (pathname === "/api/skills") {
3974
+ sendJson(response, 200, await discoverSkills({ projectRoot, home }));
3975
+ return;
3976
+ }
3977
+ if (pathname === "/api/specs") {
3978
+ sendJson(response, 200, await buildSpecsTree(projectRoot));
3979
+ return;
3980
+ }
3981
+ if (pathname === "/api/specs/file") {
3982
+ const relativePath = url2.searchParams.get("path");
3983
+ if (!relativePath) {
3984
+ sendJson(response, 400, { error: "Falta el par\xE1metro path." });
3985
+ return;
3986
+ }
3987
+ const specsRoot = join4(projectRoot, "specs");
3988
+ try {
3989
+ const file = await readSpecFile({ specsRoot, projectRoot }, relativePath);
3990
+ sendJson(response, 200, file);
3991
+ } catch (error) {
3992
+ if (error instanceof SpecPathError) {
3993
+ sendJson(response, SPEC_ERROR_STATUS[error.code] ?? 400, {
3994
+ error: error.message,
3995
+ code: error.code
3996
+ });
3997
+ return;
3998
+ }
3999
+ throw error;
4000
+ }
4001
+ return;
4002
+ }
4003
+ sendText(response, 404, "Not found");
4004
+ }
4005
+ await new Promise((resolveListen, rejectListen) => {
4006
+ const onError = (error) => {
4007
+ server.off("listening", onListening);
4008
+ rejectListen(error);
4009
+ };
4010
+ const onListening = () => {
4011
+ server.off("error", onError);
4012
+ resolveListen();
4013
+ };
4014
+ server.once("error", onError);
4015
+ server.once("listening", onListening);
4016
+ server.listen(options.port ?? 0, HOST);
4017
+ });
4018
+ const address = server.address();
4019
+ const port = address?.port ?? 0;
4020
+ listeningPort = port;
4021
+ const url = `http://${HOST}:${port}`;
4022
+ const opened = options.open === false ? false : openBrowser(url);
4023
+ void opened;
4024
+ return {
4025
+ address: { url, host: HOST, port },
4026
+ close: () => new Promise((resolveClose) => {
4027
+ server.close(() => resolveClose());
4028
+ server.closeAllConnections?.();
4029
+ })
4030
+ };
4031
+ }
4032
+
733
4033
  // src/prompts.ts
734
4034
  import inquirer from "inquirer";
735
4035
  async function selectScope() {
@@ -878,24 +4178,24 @@ async function askGlobalSkillsToo() {
878
4178
  }
879
4179
 
880
4180
  // src/global-skills.ts
881
- import { mkdir as mkdir2, cp as cp2, rm as rm2, readdir as readdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
882
- import { join as join2 } from "path";
4181
+ import { mkdir as mkdir2, cp as cp2, rm as rm2, readdir as readdir4, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
4182
+ import { join as join5 } from "path";
883
4183
  function resolveGlobalTargets() {
884
4184
  const home = process.env.HOME;
885
4185
  if (!home) return [];
886
- const gentleShellRoot = process.env.GENTLE_SHELL_HOME || join2(home, ".gentle-shell", "agent");
4186
+ const gentleShellRoot = process.env.GENTLE_SHELL_HOME || join5(home, ".gentle-shell", "agent");
887
4187
  return [
888
4188
  {
889
4189
  id: "opencode",
890
4190
  label: `OpenCode global (${home}/.config/opencode)`,
891
- root: join2(home, ".config", "opencode"),
4191
+ root: join5(home, ".config", "opencode"),
892
4192
  skillsDir: "skills",
893
4193
  commandsDir: "command"
894
4194
  },
895
4195
  {
896
4196
  id: "pi",
897
4197
  label: `Pi global (${home}/.pi/agent)`,
898
- root: join2(home, ".pi", "agent"),
4198
+ root: join5(home, ".pi", "agent"),
899
4199
  skillsDir: "skills",
900
4200
  commandsDir: "prompts"
901
4201
  },
@@ -909,7 +4209,7 @@ function resolveGlobalTargets() {
909
4209
  {
910
4210
  id: "agents",
911
4211
  label: `Agent Skills global (${home}/.agents/skills)`,
912
- root: join2(home, ".agents"),
4212
+ root: join5(home, ".agents"),
913
4213
  skillsDir: "skills"
914
4214
  }
915
4215
  ];
@@ -923,11 +4223,11 @@ function filterBundleByNames(only) {
923
4223
  async function ensureGlobalOpencodeConfig() {
924
4224
  const home = process.env.HOME;
925
4225
  if (!home) return;
926
- const configPath = join2(home, ".config", "opencode", "opencode.json");
4226
+ const configPath = join5(home, ".config", "opencode", "opencode.json");
927
4227
  try {
928
4228
  let data;
929
4229
  try {
930
- const content = await readFile2(configPath, "utf-8");
4230
+ const content = await readFile4(configPath, "utf-8");
931
4231
  data = JSON.parse(content);
932
4232
  } catch {
933
4233
  data = { agent: {} };
@@ -957,7 +4257,7 @@ function mapPlanToTarget(plan, target) {
957
4257
  if (item.to.startsWith("skills/")) {
958
4258
  mapped.push({
959
4259
  from: item.from,
960
- to: join2(target.root, target.skillsDir, item.to.slice("skills/".length)),
4260
+ to: join5(target.root, target.skillsDir, item.to.slice("skills/".length)),
961
4261
  recursive: item.recursive
962
4262
  });
963
4263
  continue;
@@ -966,7 +4266,7 @@ function mapPlanToTarget(plan, target) {
966
4266
  if (!target.commandsDir) continue;
967
4267
  mapped.push({
968
4268
  from: item.from,
969
- to: join2(target.root, target.commandsDir, item.to.slice("commands/".length)),
4269
+ to: join5(target.root, target.commandsDir, item.to.slice("commands/".length)),
970
4270
  recursive: item.recursive
971
4271
  });
972
4272
  continue;
@@ -985,7 +4285,7 @@ async function installGlobalSkills(repoRoot, targets, bundle = AGENT_BUNDLE) {
985
4285
  await rm2(item.to, { recursive: true, force: true });
986
4286
  }
987
4287
  await mkdir2(
988
- item.recursive === true ? item.to : join2(item.to, ".."),
4288
+ item.recursive === true ? item.to : join5(item.to, ".."),
989
4289
  { recursive: true }
990
4290
  );
991
4291
  await cp2(item.from, item.to, {
@@ -1011,22 +4311,22 @@ async function uninstallGlobalSkills(targets, bundle = AGENT_BUNDLE) {
1011
4311
  try {
1012
4312
  const removed = [];
1013
4313
  for (const name of skillNames) {
1014
- await rm2(join2(target.root, target.skillsDir, name), {
4314
+ await rm2(join5(target.root, target.skillsDir, name), {
1015
4315
  recursive: true,
1016
4316
  force: true
1017
4317
  });
1018
4318
  removed.push(`${target.skillsDir}/${name}`);
1019
4319
  }
1020
4320
  if (target.commandsDir) {
1021
- const cmdDir = join2(target.root, target.commandsDir);
4321
+ const cmdDir = join5(target.root, target.commandsDir);
1022
4322
  for (const name of skillNames) {
1023
- await rm2(join2(cmdDir, `${name}.md`), { force: true });
4323
+ await rm2(join5(cmdDir, `${name}.md`), { force: true });
1024
4324
  removed.push(`${target.commandsDir}/${name}.md`);
1025
4325
  try {
1026
4326
  const prefix = `${name}-`;
1027
- for (const f of await readdir2(cmdDir)) {
4327
+ for (const f of await readdir4(cmdDir)) {
1028
4328
  if (f.startsWith(prefix) && f.endsWith(".md")) {
1029
- await rm2(join2(cmdDir, f), { force: true });
4329
+ await rm2(join5(cmdDir, f), { force: true });
1030
4330
  removed.push(`${target.commandsDir}/${f}`);
1031
4331
  }
1032
4332
  }
@@ -1044,7 +4344,7 @@ async function uninstallGlobalSkills(targets, bundle = AGENT_BUNDLE) {
1044
4344
 
1045
4345
  // src/utils.ts
1046
4346
  import { existsSync } from "fs";
1047
- import { resolve } from "path";
4347
+ import { resolve as resolve4 } from "path";
1048
4348
  var PM_RUNNER = {
1049
4349
  npm: "npm",
1050
4350
  pnpm: "pnpm",
@@ -1075,7 +4375,7 @@ var BUN_SUBCOMMANDS = /* @__PURE__ */ new Set([
1075
4375
  "rebuild"
1076
4376
  ]);
1077
4377
  function getProjectPath(name) {
1078
- return resolve(process.cwd(), name);
4378
+ return resolve4(process.cwd(), name);
1079
4379
  }
1080
4380
  function projectExists(name) {
1081
4381
  return existsSync(getProjectPath(name));
@@ -1144,10 +4444,10 @@ function adaptScriptToNode(script) {
1144
4444
  return s;
1145
4445
  }
1146
4446
  async function adaptToNodeRuntime(projectPath) {
1147
- const { readFile: readFile4, writeFile: writeFile3, readdir: readdir3 } = await import("fs/promises");
1148
- const { join: join4 } = await import("path");
1149
- const pkgPath = join4(projectPath, "package.json");
1150
- const pkg = JSON.parse(await readFile4(pkgPath, "utf-8"));
4447
+ const { readFile: readFile6, writeFile: writeFile3, readdir: readdir5 } = await import("fs/promises");
4448
+ const { join: join7 } = await import("path");
4449
+ const pkgPath = join7(projectPath, "package.json");
4450
+ const pkg = JSON.parse(await readFile6(pkgPath, "utf-8"));
1151
4451
  for (const key of Object.keys(pkg.scripts ?? {})) {
1152
4452
  pkg.scripts[key] = adaptScriptToNode(pkg.scripts[key]);
1153
4453
  }
@@ -1157,27 +4457,27 @@ async function adaptToNodeRuntime(projectPath) {
1157
4457
  delete pkg.devDependencies["bun-types"];
1158
4458
  await writeFile3(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
1159
4459
  async function walk(dir) {
1160
- const entries = await readdir3(dir, { withFileTypes: true });
4460
+ const entries = await readdir5(dir, { withFileTypes: true });
1161
4461
  for (const entry of entries) {
1162
- const full = join4(dir, entry.name);
4462
+ const full = join7(dir, entry.name);
1163
4463
  if (entry.isDirectory()) {
1164
4464
  if (entry.name !== "node_modules") await walk(full);
1165
4465
  } else if (entry.name.endsWith(".test.ts")) {
1166
- const content = await readFile4(full, "utf-8");
4466
+ const content = await readFile6(full, "utf-8");
1167
4467
  if (content.includes("bun:test")) {
1168
4468
  await writeFile3(full, content.replaceAll('from "bun:test"', 'from "vitest"'));
1169
4469
  }
1170
4470
  }
1171
4471
  }
1172
4472
  }
1173
- const srcDir = join4(projectPath, "src");
4473
+ const srcDir = join7(projectPath, "src");
1174
4474
  try {
1175
4475
  await walk(srcDir);
1176
4476
  } catch {
1177
4477
  }
1178
- const tsconfigPath = join4(projectPath, "tsconfig.json");
4478
+ const tsconfigPath = join7(projectPath, "tsconfig.json");
1179
4479
  try {
1180
- const tsconfig = JSON.parse(await readFile4(tsconfigPath, "utf-8"));
4480
+ const tsconfig = JSON.parse(await readFile6(tsconfigPath, "utf-8"));
1181
4481
  if (tsconfig.compilerOptions?.types?.includes("bun-types")) {
1182
4482
  delete tsconfig.compilerOptions.types;
1183
4483
  await writeFile3(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
@@ -1187,8 +4487,8 @@ async function adaptToNodeRuntime(projectPath) {
1187
4487
  }
1188
4488
  async function writeRootGitignore(projectPath) {
1189
4489
  const { writeFile: writeFile3, access } = await import("fs/promises");
1190
- const { join: join4 } = await import("path");
1191
- const gitignorePath = join4(projectPath, ".gitignore");
4490
+ const { join: join7 } = await import("path");
4491
+ const gitignorePath = join7(projectPath, ".gitignore");
1192
4492
  try {
1193
4493
  await access(gitignorePath);
1194
4494
  } catch {
@@ -1201,12 +4501,12 @@ async function writeRootGitignore(projectPath) {
1201
4501
  async function gitInit(projectPath) {
1202
4502
  const { execFile } = await import("child_process");
1203
4503
  try {
1204
- await new Promise((resolve2, reject) => {
4504
+ await new Promise((resolve5, reject) => {
1205
4505
  execFile(
1206
4506
  "git",
1207
4507
  ["init"],
1208
4508
  { cwd: projectPath },
1209
- (err) => err ? reject(err) : resolve2()
4509
+ (err) => err ? reject(err) : resolve5()
1210
4510
  );
1211
4511
  });
1212
4512
  return true;
@@ -1216,8 +4516,8 @@ async function gitInit(projectPath) {
1216
4516
  }
1217
4517
  async function writeRootFiles(info) {
1218
4518
  const { writeFile: writeFile3 } = await import("fs/promises");
1219
- const { join: join4 } = await import("path");
1220
- const projectPath = resolve(process.cwd(), info.projectName);
4519
+ const { join: join7 } = await import("path");
4520
+ const projectPath = resolve4(process.cwd(), info.projectName);
1221
4521
  await writeRootGitignore(projectPath);
1222
4522
  const installB = info.backend.postInit?.install ? resolveCommand(info.backend.postInit.install, info.pm) : `${info.pm} install`;
1223
4523
  const devB = info.backend.postInit?.dev ? resolveCommand(info.backend.postInit.dev, info.pm) : `${info.pm} run dev`;
@@ -1252,7 +4552,7 @@ async function writeRootFiles(info) {
1252
4552
  "```",
1253
4553
  ""
1254
4554
  ].join("\n");
1255
- await writeFile3(join4(projectPath, "README.md"), readme);
4555
+ await writeFile3(join7(projectPath, "README.md"), readme);
1256
4556
  }
1257
4557
 
1258
4558
  // src/index.ts
@@ -1271,15 +4571,15 @@ var AGENT_DIRS = [".opencode", ".pi", ".agents"];
1271
4571
  async function installAgentSkills(repoRoot, projectPath) {
1272
4572
  const plan = await planAgentBundle(repoRoot);
1273
4573
  for (const agentDir of AGENT_DIRS) {
1274
- const root = join3(projectPath, agentDir);
4574
+ const root = join6(projectPath, agentDir);
1275
4575
  for (const dir of plan.skillDirs) {
1276
- await mkdir3(join3(root, dir), { recursive: true });
4576
+ await mkdir3(join6(root, dir), { recursive: true });
1277
4577
  }
1278
4578
  for (const dir of plan.commandDirs) {
1279
- await mkdir3(join3(root, dir), { recursive: true });
4579
+ await mkdir3(join6(root, dir), { recursive: true });
1280
4580
  }
1281
4581
  for (const item of plan.installs) {
1282
- await cp3(item.from, join3(root, item.to), {
4582
+ await cp3(item.from, join6(root, item.to), {
1283
4583
  recursive: item.recursive === true
1284
4584
  });
1285
4585
  }
@@ -1316,7 +4616,7 @@ async function installGlobalSkillsWithSpinner(targets, bundle) {
1316
4616
  async function readTemplatePostInit(dir) {
1317
4617
  try {
1318
4618
  const tj = JSON.parse(
1319
- await readFile3(join3(dir, "template.json"), "utf-8")
4619
+ await readFile5(join6(dir, "template.json"), "utf-8")
1320
4620
  );
1321
4621
  return tj.postInit;
1322
4622
  } catch {
@@ -1326,14 +4626,14 @@ async function readTemplatePostInit(dir) {
1326
4626
  async function createFullStackProject(projectPath, backendTpl, frontendTpl, pm, org) {
1327
4627
  const { tempDir, repoRoot } = await downloadAndExtractRepo();
1328
4628
  const projectName = basename2(projectPath);
1329
- const backendPath = join3(projectPath, "backend");
1330
- const frontendPath = join3(projectPath, "frontend");
4629
+ const backendPath = join6(projectPath, "backend");
4630
+ const frontendPath = join6(projectPath, "frontend");
1331
4631
  await copyTemplate(
1332
- join3(repoRoot, "templates", backendTpl.folder),
4632
+ join6(repoRoot, "templates", backendTpl.folder),
1333
4633
  backendPath
1334
4634
  );
1335
4635
  await copyTemplate(
1336
- join3(repoRoot, "templates", frontendTpl.folder),
4636
+ join6(repoRoot, "templates", frontendTpl.folder),
1337
4637
  frontendPath
1338
4638
  );
1339
4639
  await substituteTemplate(backendPath, projectName, backendTpl.folder, {
@@ -1493,6 +4793,64 @@ program.command("skills").description(
1493
4793
  process.exit(1);
1494
4794
  }
1495
4795
  });
4796
+ program.command("studio").description(
4797
+ "Abrir en el navegador las skills instaladas (OpenCode, Pi, Gentle Shell) y las specs/ del proyecto"
4798
+ ).option(
4799
+ "--port <puerto>",
4800
+ "Puerto fijo para el servidor local (por defecto lo elige el sistema)"
4801
+ ).option("--no-open", "No abrir el navegador autom\xE1ticamente").action(async (opts) => {
4802
+ const rawPort = opts.port?.trim();
4803
+ const port = rawPort === void 0 ? 0 : rawPort === "" ? NaN : Number(rawPort);
4804
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
4805
+ console.error(chalk.red(`
4806
+ \u2716 Error: puerto inv\xE1lido: ${opts.port}
4807
+ `));
4808
+ process.exit(1);
4809
+ return;
4810
+ }
4811
+ const projectRoot = process.cwd();
4812
+ const home = process.env.HOME;
4813
+ let skillsLine = "no disponible";
4814
+ let specsLine = "no disponible";
4815
+ try {
4816
+ const report = await discoverSkills({ projectRoot, home });
4817
+ skillsLine = `${report.totals.global + report.totals.project} (${report.totals.global} globales \xB7 ${report.totals.project} del proyecto)`;
4818
+ } catch {
4819
+ }
4820
+ try {
4821
+ const specs = await buildSpecsTree(projectRoot);
4822
+ specsLine = specs.found ? `${specs.totals.files} archivos en specs/` : "sin carpeta specs/ (creala con la skill create-specs)";
4823
+ } catch {
4824
+ }
4825
+ try {
4826
+ const studio = await startStudio({
4827
+ port,
4828
+ projectRoot,
4829
+ version: getCliVersion(),
4830
+ open: opts.open !== false
4831
+ });
4832
+ console.log(chalk.bold("\n fwinit studio\n"));
4833
+ console.log(` ${chalk.dim("Proyecto")} ${projectRoot}`);
4834
+ console.log(` ${chalk.dim("Skills")} ${skillsLine}`);
4835
+ console.log(` ${chalk.dim("Specs")} ${specsLine}`);
4836
+ console.log(` ${chalk.dim("URL")} ${chalk.cyan(studio.address.url)}`);
4837
+ console.log(`
4838
+ ${chalk.dim("Ctrl+C para cerrar")}
4839
+ `);
4840
+ const shutdown = () => {
4841
+ void studio.close().finally(() => process.exit(0));
4842
+ };
4843
+ process.once("SIGINT", shutdown);
4844
+ process.once("SIGTERM", shutdown);
4845
+ } catch (error) {
4846
+ console.error(
4847
+ chalk.red(`
4848
+ \u2716 Error: no se pudo abrir el studio: ${error.message}
4849
+ `)
4850
+ );
4851
+ process.exit(1);
4852
+ }
4853
+ });
1496
4854
  program.argument("[template]", "Template a usar (o 'fullstack' para backend + frontend)").argument("[project-name]", "Nombre del proyecto").option("-p, --pm <package-manager>", "Package manager a usar: npm, pnpm o bun").option(
1497
4855
  "-o, --org <domain>",
1498
4856
  "Organizaci\xF3n reverse-domain para el bundle id de Flutter (ej: ar.miempresa)"
@@ -1645,7 +5003,7 @@ program.argument("[template]", "Template a usar (o 'fullstack' para backend + fr
1645
5003
  spinner.succeed("Template descargado");
1646
5004
  } else {
1647
5005
  const { tempDir, templatePath, repoRoot } = await downloadAndExtract(template.folder);
1648
- const codePath = useLayerLayout ? join3(projectPath, layer) : projectPath;
5006
+ const codePath = useLayerLayout ? join6(projectPath, layer) : projectPath;
1649
5007
  spinner.text = "Creando proyecto...";
1650
5008
  await copyTemplate(templatePath, codePath);
1651
5009
  await substituteTemplate(codePath, projectName, template.folder, {