sdtk-design-kit 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -5
- package/package.json +1 -1
- package/skills/design-prototype/SKILL.md +22 -0
- package/src/commands/help.js +4 -0
- package/src/commands/init.js +84 -24
- package/src/commands/preview.js +195 -0
- package/src/commands/styles.js +140 -0
- package/src/commands/system.js +6 -0
- package/src/index.js +8 -0
- package/src/lib/design-server.js +437 -0
- package/src/lib/runtime-skills.js +46 -0
- package/src/lib/style-presets.js +160 -0
package/src/index.js
CHANGED
|
@@ -4,6 +4,8 @@ const { cmdBrief } = require("./commands/brief");
|
|
|
4
4
|
const { cmdHandoff } = require("./commands/handoff");
|
|
5
5
|
const { cmdHelp } = require("./commands/help");
|
|
6
6
|
const { cmdInit } = require("./commands/init");
|
|
7
|
+
const { cmdPreview } = require("./commands/preview");
|
|
8
|
+
const { cmdStyles } = require("./commands/styles");
|
|
7
9
|
const { cmdPrototype } = require("./commands/prototype");
|
|
8
10
|
const { cmdReview } = require("./commands/review");
|
|
9
11
|
const { cmdScreens } = require("./commands/screens");
|
|
@@ -71,6 +73,12 @@ async function run(argv) {
|
|
|
71
73
|
if (command === "prototype") {
|
|
72
74
|
return cmdPrototype(args);
|
|
73
75
|
}
|
|
76
|
+
if (command === "preview") {
|
|
77
|
+
return cmdPreview(args);
|
|
78
|
+
}
|
|
79
|
+
if (command === "styles") {
|
|
80
|
+
return cmdStyles(args);
|
|
81
|
+
}
|
|
74
82
|
if (command === "start") {
|
|
75
83
|
return cmdStart(args);
|
|
76
84
|
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// BK-275 — SDTK-DESIGN Preview Studio local server.
|
|
4
|
+
// Fork-minimal, dependency-free static server (Node stdlib only). Modeled on the
|
|
5
|
+
// sdtk-wiki viewer runner pattern but stripped to: static-serve the prototype
|
|
6
|
+
// directory, serve the single-file studio at "/" and "/studio", and accept ONE
|
|
7
|
+
// POST endpoint (/api/feedback) that writes a scoped feedback artifact to a
|
|
8
|
+
// local file. It NEVER shells out to an agent (manual-apply boundary, BK-275 D3).
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const http = require("http");
|
|
12
|
+
const net = require("net");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { describeDesignPaths, isPathInsideOrEqual } = require("./design-paths");
|
|
15
|
+
|
|
16
|
+
const JSON_HEADERS = {
|
|
17
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
18
|
+
"Cache-Control": "no-cache",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const MIME_TYPES = {
|
|
22
|
+
".html": "text/html; charset=utf-8",
|
|
23
|
+
".js": "application/javascript; charset=utf-8",
|
|
24
|
+
".json": "application/json; charset=utf-8",
|
|
25
|
+
".css": "text/css; charset=utf-8",
|
|
26
|
+
".svg": "image/svg+xml",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".jpg": "image/jpeg",
|
|
29
|
+
".jpeg": "image/jpeg",
|
|
30
|
+
".gif": "image/gif",
|
|
31
|
+
".webp": "image/webp",
|
|
32
|
+
".ico": "image/x-icon",
|
|
33
|
+
".woff": "font/woff",
|
|
34
|
+
".woff2": "font/woff2",
|
|
35
|
+
".ttf": "font/ttf",
|
|
36
|
+
".md": "text/plain; charset=utf-8",
|
|
37
|
+
".txt": "text/plain; charset=utf-8",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB cap for a feedback batch.
|
|
41
|
+
const HEALTH_CHECK_RETRIES = 20;
|
|
42
|
+
const HEALTH_CHECK_INTERVAL_MS = 150;
|
|
43
|
+
|
|
44
|
+
const HARD_SCOPE_PREAMBLE =
|
|
45
|
+
"Hard scope: change ONLY the elements identified below by screen / selector / stable-id / position. " +
|
|
46
|
+
"Do NOT modify sibling screens, parent layout, global CSS, design tokens, or unrelated rules even if you " +
|
|
47
|
+
"notice issues there — surface those as a follow-up note instead of editing them. If a request cannot be " +
|
|
48
|
+
"satisfied without touching outside this scope, ask the user before proceeding.";
|
|
49
|
+
|
|
50
|
+
function isPortOpen(host, port) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const socket = net.createConnection({ host, port, timeout: 500 });
|
|
53
|
+
socket.once("connect", () => {
|
|
54
|
+
socket.destroy();
|
|
55
|
+
resolve(true);
|
|
56
|
+
});
|
|
57
|
+
socket.once("error", () => resolve(false));
|
|
58
|
+
socket.once("timeout", () => {
|
|
59
|
+
socket.destroy();
|
|
60
|
+
resolve(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function findFreePort(host, startPort) {
|
|
66
|
+
for (let p = startPort; p < startPort + 20; p += 1) {
|
|
67
|
+
const busy = await isPortOpen(host, p);
|
|
68
|
+
if (!busy) return p;
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Could not find a free port in range ${startPort}-${startPort + 19}. ` +
|
|
72
|
+
"Stop unused preview servers or pass a different --port."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function waitForServer(host, port) {
|
|
77
|
+
for (let i = 0; i < HEALTH_CHECK_RETRIES; i += 1) {
|
|
78
|
+
const ok = await isPortOpen(host, port);
|
|
79
|
+
if (ok) return;
|
|
80
|
+
await new Promise((r) => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`SDTK-DESIGN preview server did not start on http://${host}:${port}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function feedbackStamp(date = new Date()) {
|
|
86
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
87
|
+
return (
|
|
88
|
+
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
|
|
89
|
+
`T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function trimText(value, max) {
|
|
94
|
+
const text = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
|
|
95
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizePosition(input) {
|
|
99
|
+
const finite = (v) => (Number.isFinite(v) ? Math.round(v) : 0);
|
|
100
|
+
const pos = input || {};
|
|
101
|
+
return { x: finite(pos.x), y: finite(pos.y), width: finite(pos.width), height: finite(pos.height) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatComputedStyle(style) {
|
|
105
|
+
if (!style || typeof style !== "object") return "";
|
|
106
|
+
return Object.keys(style)
|
|
107
|
+
.map((key) => {
|
|
108
|
+
const value = style[key];
|
|
109
|
+
return value ? `${key}: ${trimText(value, 80)}` : null;
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join("; ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderTargetLines(target, indent = "") {
|
|
116
|
+
const t = target || {};
|
|
117
|
+
const pos = normalizePosition(t.position);
|
|
118
|
+
const lines = [
|
|
119
|
+
`${indent}selector: ${trimText(t.selector, 200) || "(none)"}`,
|
|
120
|
+
`${indent}label: ${trimText(t.label, 120) || "(unlabeled)"}`,
|
|
121
|
+
`${indent}position: x${pos.x} y${pos.y} ${pos.width}x${pos.height}`,
|
|
122
|
+
`${indent}currentText: ${trimText(t.currentText, 160) || "(empty)"}`,
|
|
123
|
+
`${indent}htmlHint: ${trimText(t.htmlHint, 200) || "(none)"}`,
|
|
124
|
+
`${indent}computedStyle: ${formatComputedStyle(t.computedStyle) || "(none)"}`,
|
|
125
|
+
];
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Pure, exported — the unit-test target. Ports the shape of Open Design's
|
|
130
|
+
// comments.ts#renderCommentAttachmentContext into SDTK screen/file vocabulary.
|
|
131
|
+
function compileFeedbackMarkdown(marks, options = {}) {
|
|
132
|
+
const list = Array.isArray(marks) ? marks : [];
|
|
133
|
+
const generatedAt = options.generatedAt || new Date().toISOString();
|
|
134
|
+
const manifestRelPath = options.manifestRelPath || "docs/design/prototype/.manifest.json";
|
|
135
|
+
|
|
136
|
+
const out = [];
|
|
137
|
+
out.push("---");
|
|
138
|
+
out.push("schema: sdtk.design.feedback.v1");
|
|
139
|
+
out.push(`generatedAt: ${generatedAt}`);
|
|
140
|
+
out.push(`prototypeManifest: ${manifestRelPath}`);
|
|
141
|
+
out.push(`markCount: ${list.length}`);
|
|
142
|
+
out.push("---");
|
|
143
|
+
out.push("");
|
|
144
|
+
out.push("<attached-preview-comments>");
|
|
145
|
+
out.push(HARD_SCOPE_PREAMBLE);
|
|
146
|
+
|
|
147
|
+
list.forEach((mark, index) => {
|
|
148
|
+
const rawKind = mark && mark.kind;
|
|
149
|
+
const kind = rawKind === "pod" ? "pod" : rawKind === "token" ? "token" : "element";
|
|
150
|
+
|
|
151
|
+
// Token marks are GLOBAL design-token changes (not anchored to one screen):
|
|
152
|
+
// render the token name/old/new + scope instead of an element descriptor.
|
|
153
|
+
if (kind === "token") {
|
|
154
|
+
const tokenName = trimText(mark && mark.token, 120) || `token-${index + 1}`;
|
|
155
|
+
out.push("");
|
|
156
|
+
out.push(`${index + 1}. ${tokenName}`);
|
|
157
|
+
out.push(` targetKind: token`);
|
|
158
|
+
out.push(` scope: ${trimText(mark && mark.scope, 40) || "global"}`);
|
|
159
|
+
out.push(` token: ${tokenName}`);
|
|
160
|
+
out.push(` oldValue: ${trimText(mark && mark.oldValue, 120) || "(unknown)"}`);
|
|
161
|
+
out.push(` newValue: ${trimText(mark && mark.newValue, 120) || "(unset)"}`);
|
|
162
|
+
const tNote = trimText(mark && mark.note, 600);
|
|
163
|
+
out.push(` comment: ${tNote || "(no note — apply the token change above)"}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const screenId = trimText(mark && mark.screenId, 120) || "(unknown-screen)";
|
|
168
|
+
const stableId = trimText(mark && (mark.target && mark.target.stableId), 200) ||
|
|
169
|
+
trimText(mark && mark.stableId, 200) || `mark-${index + 1}`;
|
|
170
|
+
out.push("");
|
|
171
|
+
out.push(`${index + 1}. ${stableId}`);
|
|
172
|
+
out.push(` targetKind: ${kind}`);
|
|
173
|
+
out.push(` screen: ${screenId} (file: docs/design/prototype/screens/${screenId}.html)`);
|
|
174
|
+
if (kind === "pod") {
|
|
175
|
+
const members = Array.isArray(mark.members) ? mark.members : [];
|
|
176
|
+
out.push(` memberCount: ${members.length}`);
|
|
177
|
+
members.slice(0, 12).forEach((member, mi) => {
|
|
178
|
+
const mStable = trimText(member && member.stableId, 200) || `member-${mi + 1}`;
|
|
179
|
+
out.push(` member.${mi + 1}: ${mStable}`);
|
|
180
|
+
renderTargetLines(member, " ").forEach((line) => out.push(line));
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
renderTargetLines(mark && mark.target, " ").forEach((line) => out.push(line));
|
|
184
|
+
}
|
|
185
|
+
const note = trimText(mark && mark.note, 600);
|
|
186
|
+
out.push(` comment: ${note || "(no note — see target)"}`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
out.push("</attached-preview-comments>");
|
|
190
|
+
out.push("");
|
|
191
|
+
return out.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Parse the optional `sdtk:` block from a SKILL.md YAML front-matter. Tolerant
|
|
195
|
+
// by contract: returns {} on anything unexpected (never throws). Supports the
|
|
196
|
+
// documented shape only — preview.entry, parameters (inline-object list items),
|
|
197
|
+
// capabilities_required (inline [list]). A full YAML parser is intentionally
|
|
198
|
+
// NOT a dependency.
|
|
199
|
+
function parseInlineObject(line) {
|
|
200
|
+
// "- { name: accent, type: color, min: 0, max: 24 }" -> { name, type, min, max }
|
|
201
|
+
// Strips from the last "}" so a trailing YAML comment ("} # note") is ignored.
|
|
202
|
+
// NOTE: values must be comma-free scalars (the schema only uses name/type/min/max).
|
|
203
|
+
const body = line.replace(/^\s*-\s*\{/, "").replace(/\}[^}]*$/, "");
|
|
204
|
+
const obj = {};
|
|
205
|
+
body.split(",").forEach((pair) => {
|
|
206
|
+
const idx = pair.indexOf(":");
|
|
207
|
+
if (idx === -1) return;
|
|
208
|
+
const key = pair.slice(0, idx).trim();
|
|
209
|
+
let value = pair.slice(idx + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
210
|
+
if (!key) return;
|
|
211
|
+
if ((key === "min" || key === "max") && value !== "" && Number.isFinite(Number(value))) {
|
|
212
|
+
value = Number(value);
|
|
213
|
+
}
|
|
214
|
+
obj[key] = value;
|
|
215
|
+
});
|
|
216
|
+
return obj;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseSkillMeta(skillMdText) {
|
|
220
|
+
try {
|
|
221
|
+
// Strip a leading UTF-8 BOM (editors add it; fs 'utf-8' does not remove it).
|
|
222
|
+
const text = String(skillMdText || "").replace(/^\uFEFF/, "");
|
|
223
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
224
|
+
if (!fm) return {};
|
|
225
|
+
const lines = fm[1].split(/\r?\n/);
|
|
226
|
+
const startIdx = lines.findIndex((l) => /^sdtk:\s*$/.test(l));
|
|
227
|
+
if (startIdx === -1) return {};
|
|
228
|
+
const block = [];
|
|
229
|
+
for (let i = startIdx + 1; i < lines.length; i += 1) {
|
|
230
|
+
const line = lines[i];
|
|
231
|
+
if (line.trim() === "") continue;
|
|
232
|
+
if (/^\s/.test(line)) block.push(line);
|
|
233
|
+
else break; // dedented back to top level → end of sdtk block
|
|
234
|
+
}
|
|
235
|
+
const meta = {};
|
|
236
|
+
const parameters = [];
|
|
237
|
+
let inParameters = false;
|
|
238
|
+
for (const line of block) {
|
|
239
|
+
const trimmed = line.trim();
|
|
240
|
+
const indent = line.length - line.replace(/^\s+/, "").length;
|
|
241
|
+
if (/^preview:\s*$/.test(trimmed)) { inParameters = false; continue; }
|
|
242
|
+
const entryMatch = /^entry:\s*(.+)$/.exec(trimmed);
|
|
243
|
+
if (entryMatch) {
|
|
244
|
+
meta.preview = meta.preview || {};
|
|
245
|
+
meta.preview.entry = entryMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (/^parameters:\s*$/.test(trimmed)) { inParameters = true; continue; }
|
|
249
|
+
const capsMatch = /^capabilities_required:\s*\[(.*)\]\s*$/.exec(trimmed);
|
|
250
|
+
if (capsMatch) {
|
|
251
|
+
meta.capabilitiesRequired = capsMatch[1]
|
|
252
|
+
.split(",").map((c) => c.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
253
|
+
inParameters = false;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (inParameters && /^-\s*\{.*\}\s*(#.*)?$/.test(trimmed) && indent >= 4) {
|
|
257
|
+
const obj = parseInlineObject(line);
|
|
258
|
+
if (obj.name) parameters.push(obj);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (trimmed && !/^-/.test(trimmed) && indent <= 2) inParameters = false;
|
|
262
|
+
}
|
|
263
|
+
if (parameters.length) meta.parameters = parameters;
|
|
264
|
+
return meta;
|
|
265
|
+
} catch (_) {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function serveStatic(req, res, paths) {
|
|
271
|
+
let urlPath;
|
|
272
|
+
try {
|
|
273
|
+
urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
|
274
|
+
} catch (_) {
|
|
275
|
+
// Malformed percent-encoding must not crash the server.
|
|
276
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
277
|
+
res.end("Bad request");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const rel = urlPath.replace(/^\/+/, "");
|
|
281
|
+
const candidate = path.join(paths.prototypePath, rel);
|
|
282
|
+
if (!isPathInsideOrEqual(candidate, paths.prototypePath)) {
|
|
283
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
284
|
+
res.end("Not found");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
fs.stat(candidate, (err, stat) => {
|
|
288
|
+
if (err || !stat.isFile()) {
|
|
289
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
290
|
+
res.end("Not found");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const ext = path.extname(candidate).toLowerCase();
|
|
294
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream", "Cache-Control": "no-cache" });
|
|
295
|
+
fs.createReadStream(candidate).pipe(res);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function handleFeedbackPost(req, res, paths) {
|
|
300
|
+
let body = "";
|
|
301
|
+
let aborted = false;
|
|
302
|
+
req.on("data", (chunk) => {
|
|
303
|
+
if (aborted) return;
|
|
304
|
+
body += chunk;
|
|
305
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
306
|
+
aborted = true;
|
|
307
|
+
res.writeHead(413, JSON_HEADERS);
|
|
308
|
+
res.end(JSON.stringify({ ok: false, error: "Feedback body too large." }));
|
|
309
|
+
req.destroy();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
req.on("end", () => {
|
|
313
|
+
if (aborted) return;
|
|
314
|
+
let payload;
|
|
315
|
+
try {
|
|
316
|
+
payload = body ? JSON.parse(body) : {};
|
|
317
|
+
} catch (_) {
|
|
318
|
+
res.writeHead(400, JSON_HEADERS);
|
|
319
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body." }));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const marks = Array.isArray(payload.marks) ? payload.marks : [];
|
|
323
|
+
if (marks.length === 0) {
|
|
324
|
+
res.writeHead(400, JSON_HEADERS);
|
|
325
|
+
res.end(JSON.stringify({ ok: false, error: "No marks to send." }));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const generatedAt = new Date();
|
|
329
|
+
const markdown = compileFeedbackMarkdown(marks, {
|
|
330
|
+
generatedAt: generatedAt.toISOString(),
|
|
331
|
+
manifestRelPath: "docs/design/prototype/.manifest.json",
|
|
332
|
+
});
|
|
333
|
+
const feedbackDir = path.join(paths.designDocsPath, "feedback");
|
|
334
|
+
const relPath = `docs/design/feedback/DESIGN_FEEDBACK_${feedbackStamp(generatedAt)}.md`;
|
|
335
|
+
const outPath = path.join(paths.projectPath, relPath);
|
|
336
|
+
if (!isPathInsideOrEqual(outPath, paths.designDocsPath)) {
|
|
337
|
+
res.writeHead(400, JSON_HEADERS);
|
|
338
|
+
res.end(JSON.stringify({ ok: false, error: "Refusing to write outside docs/design." }));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
fs.mkdirSync(feedbackDir, { recursive: true });
|
|
343
|
+
fs.writeFileSync(outPath, `${markdown}`, "utf-8");
|
|
344
|
+
} catch (err) {
|
|
345
|
+
res.writeHead(500, JSON_HEADERS);
|
|
346
|
+
res.end(JSON.stringify({ ok: false, error: `Could not write feedback: ${err.message}` }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
res.writeHead(200, JSON_HEADERS);
|
|
350
|
+
res.end(JSON.stringify({ ok: true, path: relPath }));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function loadSkillMeta(skillMetaPath) {
|
|
355
|
+
if (!skillMetaPath) return {};
|
|
356
|
+
try {
|
|
357
|
+
return parseSkillMeta(fs.readFileSync(skillMetaPath, "utf-8"));
|
|
358
|
+
} catch (_) {
|
|
359
|
+
// Missing/unreadable SKILL.md must not break the studio — fall back to {}.
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function startDesignServer({ host = "127.0.0.1", port, projectPath, studioHtml, skillMetaPath, styles, defaultStyle }) {
|
|
365
|
+
const paths = describeDesignPaths(projectPath);
|
|
366
|
+
const styleCatalog = Array.isArray(styles) ? styles : [];
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
const server = http.createServer((req, res) => {
|
|
369
|
+
const method = (req.method || "GET").toUpperCase();
|
|
370
|
+
const pathOnly = (req.url || "/").split("?")[0];
|
|
371
|
+
|
|
372
|
+
if (pathOnly === "/" || pathOnly === "/studio" || pathOnly === "/studio/") {
|
|
373
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
374
|
+
res.end(studioHtml);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (pathOnly === "/api/health") {
|
|
378
|
+
res.writeHead(200, JSON_HEADERS);
|
|
379
|
+
res.end(JSON.stringify({ ok: true, projectPath: paths.projectPath }));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (pathOnly === "/api/styles") {
|
|
383
|
+
if (method !== "GET") {
|
|
384
|
+
res.writeHead(405, JSON_HEADERS);
|
|
385
|
+
res.end(JSON.stringify({ ok: false, error: "Method not allowed." }));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// Read-only style catalog for the visual gallery (display-safe fields only).
|
|
389
|
+
res.writeHead(200, JSON_HEADERS);
|
|
390
|
+
res.end(JSON.stringify({
|
|
391
|
+
ok: true,
|
|
392
|
+
defaultStyle: defaultStyle || (styleCatalog.length ? styleCatalog[0].name : null),
|
|
393
|
+
styles: styleCatalog,
|
|
394
|
+
}));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (pathOnly === "/api/skill-meta") {
|
|
398
|
+
if (method !== "GET") {
|
|
399
|
+
res.writeHead(405, JSON_HEADERS);
|
|
400
|
+
res.end(JSON.stringify({ ok: false, error: "Method not allowed." }));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Read-only; tolerant — never 500 on a malformed/absent SKILL.md.
|
|
404
|
+
res.writeHead(200, JSON_HEADERS);
|
|
405
|
+
res.end(JSON.stringify({ ok: true, sdtk: loadSkillMeta(skillMetaPath) }));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (pathOnly === "/api/feedback") {
|
|
409
|
+
if (method !== "POST") {
|
|
410
|
+
res.writeHead(405, JSON_HEADERS);
|
|
411
|
+
res.end(JSON.stringify({ ok: false, error: "Method not allowed." }));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
handleFeedbackPost(req, res, paths);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
serveStatic(req, res, paths);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
server.on("error", reject);
|
|
421
|
+
server.listen(port, host, () => {
|
|
422
|
+
resolve({ server, port, url: `http://${host}:${port}/` });
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
HARD_SCOPE_PREAMBLE,
|
|
429
|
+
MAX_BODY_BYTES,
|
|
430
|
+
compileFeedbackMarkdown,
|
|
431
|
+
feedbackStamp,
|
|
432
|
+
findFreePort,
|
|
433
|
+
isPortOpen,
|
|
434
|
+
parseSkillMeta,
|
|
435
|
+
startDesignServer,
|
|
436
|
+
waitForServer,
|
|
437
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Runtime/scope → skills directory resolver for SDTK-DESIGN.
|
|
4
|
+
//
|
|
5
|
+
// Mirrors the convention used by the runtime-aware kits (sdtk-spec/ops/code,
|
|
6
|
+
// see their src/lib/scope.js): claude installs skills under `.claude/skills`,
|
|
7
|
+
// codex under `.codex/skills` (honoring CODEX_HOME for the user/global scope).
|
|
8
|
+
// Each kit is an independent npm package, so this small resolver is duplicated
|
|
9
|
+
// rather than imported across package boundaries.
|
|
10
|
+
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
|
|
14
|
+
const VALID_RUNTIMES = ["claude", "codex"];
|
|
15
|
+
const VALID_SCOPES = ["project", "user"];
|
|
16
|
+
|
|
17
|
+
// Claude defaults to project-local; Codex defaults to user/global.
|
|
18
|
+
function defaultScope(runtime) {
|
|
19
|
+
return runtime === "claude" ? "project" : "user";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveCodexHome(scope, projectPath) {
|
|
23
|
+
if (scope === "project") {
|
|
24
|
+
return path.join(projectPath || process.cwd(), ".codex");
|
|
25
|
+
}
|
|
26
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Absolute skills directory for the given runtime/scope/project root.
|
|
30
|
+
function resolveSkillsDir(runtime, scope, projectPath) {
|
|
31
|
+
if (runtime === "claude") {
|
|
32
|
+
if (scope === "user") {
|
|
33
|
+
return path.join(os.homedir(), ".claude", "skills");
|
|
34
|
+
}
|
|
35
|
+
return path.join(projectPath || process.cwd(), ".claude", "skills");
|
|
36
|
+
}
|
|
37
|
+
return path.join(resolveCodexHome(scope, projectPath), "skills");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
VALID_RUNTIMES,
|
|
42
|
+
VALID_SCOPES,
|
|
43
|
+
defaultScope,
|
|
44
|
+
resolveCodexHome,
|
|
45
|
+
resolveSkillsDir,
|
|
46
|
+
};
|