openspec-dashboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -0
- package/dist/bin/cli.js +49 -0
- package/dist/bin/skill-hook.js +174 -0
- package/dist/chunk-YBLVDJ3Y.js +1232 -0
- package/dist/client/assets/index-LwI8lMI7.js +97 -0
- package/dist/client/assets/index-keJ4Pk8f.css +1 -0
- package/dist/client/index.html +23 -0
- package/dist/server/index.js +8 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import express from "express";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import path11 from "path";
|
|
5
|
+
|
|
6
|
+
// src/server/routes/config.ts
|
|
7
|
+
import { Router } from "express";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { spawn } from "child_process";
|
|
11
|
+
function createConfigRouter({ getData, getOpenspecDir }) {
|
|
12
|
+
const router = Router();
|
|
13
|
+
router.get("/", (_req, res) => {
|
|
14
|
+
const openspecDir = getOpenspecDir();
|
|
15
|
+
if (!fs.existsSync(openspecDir)) {
|
|
16
|
+
res.json({
|
|
17
|
+
schema: "spec-driven",
|
|
18
|
+
defaultSchema: "spec-driven",
|
|
19
|
+
availableSchemas: [],
|
|
20
|
+
_notInitialized: true,
|
|
21
|
+
_message: "openspec \u76EE\u5F55\u4E0D\u5B58\u5728\uFF0C\u8BF7\u5148\u5728\u9879\u76EE\u6839\u76EE\u5F55\u8FD0\u884C openspec \u521D\u59CB\u5316\u547D\u4EE4"
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
res.json(getData().config);
|
|
26
|
+
});
|
|
27
|
+
router.get("/initialized", (_req, res) => {
|
|
28
|
+
const openspecDir = getOpenspecDir();
|
|
29
|
+
res.json({
|
|
30
|
+
initialized: fs.existsSync(openspecDir),
|
|
31
|
+
path: openspecDir
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
router.post("/init", async (req, res) => {
|
|
35
|
+
const openspecDir = getOpenspecDir();
|
|
36
|
+
const projectDir = path.dirname(openspecDir);
|
|
37
|
+
if (fs.existsSync(openspecDir)) {
|
|
38
|
+
res.status(400).json({ error: "OpenSpec \u5DF2\u521D\u59CB\u5316" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
const check = spawn("openspec", ["--version"], {
|
|
44
|
+
cwd: projectDir
|
|
45
|
+
});
|
|
46
|
+
check.on("close", (code) => {
|
|
47
|
+
if (code === 0) resolve();
|
|
48
|
+
else reject(new Error("openspec CLI not found"));
|
|
49
|
+
});
|
|
50
|
+
check.on("error", reject);
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
res.status(400).json({
|
|
54
|
+
error: "\u672A\u5B89\u88C5 openspec CLI",
|
|
55
|
+
hint: "\u8BF7\u5148\u5B89\u88C5: npm install -g @fission-ai/openspec"
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const proc = spawn("openspec", ["init", "--tools", "claude"], {
|
|
60
|
+
cwd: projectDir,
|
|
61
|
+
stdio: "inherit"
|
|
62
|
+
});
|
|
63
|
+
proc.on("close", (code) => {
|
|
64
|
+
if (code === 0) {
|
|
65
|
+
res.json({ status: "success", path: openspecDir });
|
|
66
|
+
} else {
|
|
67
|
+
res.status(500).json({ error: `\u521D\u59CB\u5316\u5931\u8D25\uFF0C\u9000\u51FA\u7801: ${code}` });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
proc.on("error", (err) => {
|
|
71
|
+
res.status(500).json({ error: `\u521D\u59CB\u5316\u5931\u8D25: ${err.message}` });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
return router;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/server/routes/changes.ts
|
|
78
|
+
import { Router as Router2 } from "express";
|
|
79
|
+
import fs6 from "fs";
|
|
80
|
+
import path5 from "path";
|
|
81
|
+
|
|
82
|
+
// src/server/parser/config-parser.ts
|
|
83
|
+
import fs2 from "fs/promises";
|
|
84
|
+
import path2 from "path";
|
|
85
|
+
import yaml from "js-yaml";
|
|
86
|
+
async function parseConfig(openspecDir) {
|
|
87
|
+
const configPath = path2.join(openspecDir, "config.yaml");
|
|
88
|
+
try {
|
|
89
|
+
const content = await fs2.readFile(configPath, "utf-8");
|
|
90
|
+
const raw = yaml.load(content);
|
|
91
|
+
return {
|
|
92
|
+
schema: raw?.schema ?? "spec-driven",
|
|
93
|
+
context: raw?.context,
|
|
94
|
+
defaultSchema: raw?.default_schema ?? raw?.schema ?? "spec-driven",
|
|
95
|
+
availableSchemas: []
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return {
|
|
99
|
+
schema: "spec-driven",
|
|
100
|
+
defaultSchema: "spec-driven",
|
|
101
|
+
availableSchemas: []
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/server/parser/schema-parser.ts
|
|
107
|
+
import fs3 from "fs/promises";
|
|
108
|
+
import path3 from "path";
|
|
109
|
+
import yaml2 from "js-yaml";
|
|
110
|
+
async function parseSchemas(openspecDir) {
|
|
111
|
+
const schemasDir = path3.join(openspecDir, "schemas");
|
|
112
|
+
try {
|
|
113
|
+
const entries = await fs3.readdir(schemasDir, { withFileTypes: true });
|
|
114
|
+
const schemas = [];
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory()) continue;
|
|
117
|
+
const schemaPath = path3.join(schemasDir, entry.name, "schema.yaml");
|
|
118
|
+
try {
|
|
119
|
+
const content = await fs3.readFile(schemaPath, "utf-8");
|
|
120
|
+
const raw = yaml2.load(content);
|
|
121
|
+
schemas.push({
|
|
122
|
+
name: raw.name,
|
|
123
|
+
version: raw.version,
|
|
124
|
+
description: raw.description,
|
|
125
|
+
artifacts: raw.artifacts.map((a) => ({
|
|
126
|
+
id: a.id,
|
|
127
|
+
generates: a.generates,
|
|
128
|
+
description: a.description,
|
|
129
|
+
requires: a.requires ?? []
|
|
130
|
+
}))
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return schemas;
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/server/parser/change-parser.ts
|
|
142
|
+
import fs5 from "fs/promises";
|
|
143
|
+
import path4 from "path";
|
|
144
|
+
import yaml3 from "js-yaml";
|
|
145
|
+
|
|
146
|
+
// src/server/parser/task-parser.ts
|
|
147
|
+
import fs4 from "fs/promises";
|
|
148
|
+
function parseTaskProgress(content) {
|
|
149
|
+
const checkboxRegex = /^- \[([ x])\]/gm;
|
|
150
|
+
let total = 0;
|
|
151
|
+
let completed = 0;
|
|
152
|
+
let match;
|
|
153
|
+
while ((match = checkboxRegex.exec(content)) !== null) {
|
|
154
|
+
total++;
|
|
155
|
+
if (match[1] === "x") completed++;
|
|
156
|
+
}
|
|
157
|
+
return { total, completed };
|
|
158
|
+
}
|
|
159
|
+
async function parseTaskProgressFromFile(filePath) {
|
|
160
|
+
try {
|
|
161
|
+
const content = await fs4.readFile(filePath, "utf-8");
|
|
162
|
+
return parseTaskProgress(content);
|
|
163
|
+
} catch {
|
|
164
|
+
return void 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/server/parser/change-parser.ts
|
|
169
|
+
function deriveChangeType(name) {
|
|
170
|
+
if (name.startsWith("feat-")) return "feat";
|
|
171
|
+
if (name.startsWith("fix-")) return "fix";
|
|
172
|
+
if (name.startsWith("hotfix-")) return "hotfix";
|
|
173
|
+
return "unknown";
|
|
174
|
+
}
|
|
175
|
+
function deriveDisplayName(name) {
|
|
176
|
+
const withoutPrefix = name.replace(/^(feat|fix|hotfix)-/, "");
|
|
177
|
+
return withoutPrefix.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
178
|
+
}
|
|
179
|
+
function findSchema(schemaName, schemas) {
|
|
180
|
+
return schemas.find((s) => s.name === schemaName);
|
|
181
|
+
}
|
|
182
|
+
async function fileExists(filePath) {
|
|
183
|
+
try {
|
|
184
|
+
await fs5.access(filePath);
|
|
185
|
+
return true;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function resolveArtifactStatus(changeDir, schema) {
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const artifact of schema.artifacts) {
|
|
193
|
+
if (artifact.generates.includes("*")) {
|
|
194
|
+
const files = await findFiles(changeDir, artifact.generates);
|
|
195
|
+
results.push({
|
|
196
|
+
id: artifact.id,
|
|
197
|
+
status: files.length > 0 ? "done" : "pending",
|
|
198
|
+
filePath: files[0]
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
const filePath = path4.join(changeDir, artifact.generates);
|
|
202
|
+
const exists = await fileExists(filePath);
|
|
203
|
+
results.push({
|
|
204
|
+
id: artifact.id,
|
|
205
|
+
status: exists ? "done" : "pending",
|
|
206
|
+
filePath: exists ? filePath : void 0
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
async function findFiles(baseDir, pattern) {
|
|
213
|
+
const parts = pattern.split("/");
|
|
214
|
+
const results = [];
|
|
215
|
+
await walkForPattern(baseDir, parts, 0, results);
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
async function walkForPattern(dir, parts, idx, results) {
|
|
219
|
+
if (idx >= parts.length) return;
|
|
220
|
+
const part = parts[idx];
|
|
221
|
+
if (part === "**") {
|
|
222
|
+
await walkRecursive(dir, parts, idx + 1, results);
|
|
223
|
+
} else if (part.includes("*")) {
|
|
224
|
+
const regex = new RegExp("^" + part.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
|
|
225
|
+
try {
|
|
226
|
+
const entries = await fs5.readdir(dir, { withFileTypes: true });
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.isFile() && regex.test(entry.name) && idx === parts.length - 1) {
|
|
229
|
+
results.push(path4.join(dir, entry.name));
|
|
230
|
+
} else if (entry.isDirectory() && regex.test(entry.name)) {
|
|
231
|
+
await walkForPattern(path4.join(dir, entry.name), parts, idx + 1, results);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
const nextDir = path4.join(dir, part);
|
|
238
|
+
if (idx === parts.length - 1) {
|
|
239
|
+
if (await fileExists(nextDir)) results.push(nextDir);
|
|
240
|
+
} else {
|
|
241
|
+
await walkForPattern(nextDir, parts, idx + 1, results);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function walkRecursive(dir, parts, nextIdx, results) {
|
|
246
|
+
try {
|
|
247
|
+
const entries = await fs5.readdir(dir, { withFileTypes: true });
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
const fullPath = path4.join(dir, entry.name);
|
|
250
|
+
if (entry.isFile()) {
|
|
251
|
+
if (nextIdx < parts.length) {
|
|
252
|
+
const regex = new RegExp("^" + parts[nextIdx].replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
|
|
253
|
+
if (regex.test(entry.name) && nextIdx === parts.length - 1) {
|
|
254
|
+
results.push(fullPath);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else if (entry.isDirectory()) {
|
|
258
|
+
if (nextIdx < parts.length && entry.name === parts[nextIdx]) {
|
|
259
|
+
await walkForPattern(fullPath, parts, nextIdx + 1, results);
|
|
260
|
+
}
|
|
261
|
+
await walkRecursive(fullPath, parts, nextIdx, results);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function determineStatus(isArchived, hasDesign, hasProposal, hasTasks) {
|
|
268
|
+
if (isArchived) return "archived";
|
|
269
|
+
if (hasDesign && hasProposal && hasTasks) {
|
|
270
|
+
return "ready";
|
|
271
|
+
}
|
|
272
|
+
return "in_progress";
|
|
273
|
+
}
|
|
274
|
+
async function parseOneChange(changeDir, name, schemas, isArchived) {
|
|
275
|
+
const configPath = path4.join(changeDir, ".openspec.yaml");
|
|
276
|
+
let schemaName = "spec-driven";
|
|
277
|
+
let createdAt;
|
|
278
|
+
try {
|
|
279
|
+
const raw = yaml3.load(await fs5.readFile(configPath, "utf-8"));
|
|
280
|
+
schemaName = raw?.schema ?? "spec-driven";
|
|
281
|
+
createdAt = raw?.created;
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
if (!createdAt) {
|
|
285
|
+
try {
|
|
286
|
+
const stats = await fs5.stat(changeDir);
|
|
287
|
+
createdAt = stats.birthtime.toISOString();
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const hasDesign = await fileExists(path4.join(changeDir, "design.md"));
|
|
292
|
+
const hasProposal = await fileExists(path4.join(changeDir, "proposal.md"));
|
|
293
|
+
const hasTasks = await fileExists(path4.join(changeDir, "tasks.md"));
|
|
294
|
+
const taskProgress = await parseTaskProgressFromFile(path4.join(changeDir, "tasks.md"));
|
|
295
|
+
const specsDir = path4.join(changeDir, "specs");
|
|
296
|
+
let applied = false;
|
|
297
|
+
try {
|
|
298
|
+
const specEntries = await fs5.readdir(specsDir, { withFileTypes: true });
|
|
299
|
+
const hasSpecDirs = specEntries.some((e) => e.isDirectory());
|
|
300
|
+
if (hasSpecDirs && taskProgress && taskProgress.completed >= taskProgress.total) {
|
|
301
|
+
applied = true;
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
}
|
|
305
|
+
const defaultArtifacts = [
|
|
306
|
+
{ id: "proposal", status: hasProposal ? "done" : "pending", filePath: hasProposal ? path4.join(changeDir, "proposal.md") : void 0 },
|
|
307
|
+
{ id: "design", status: hasDesign ? "done" : "pending", filePath: hasDesign ? path4.join(changeDir, "design.md") : void 0 },
|
|
308
|
+
{ id: "tasks", status: hasTasks ? "done" : "pending", filePath: hasTasks ? path4.join(changeDir, "tasks.md") : void 0 }
|
|
309
|
+
];
|
|
310
|
+
const schema = findSchema(schemaName, schemas);
|
|
311
|
+
const artifacts = schema ? await resolveArtifactStatus(changeDir, schema) : defaultArtifacts;
|
|
312
|
+
return {
|
|
313
|
+
name,
|
|
314
|
+
displayName: deriveDisplayName(name),
|
|
315
|
+
type: deriveChangeType(name),
|
|
316
|
+
status: determineStatus(isArchived, hasDesign, hasProposal, hasTasks),
|
|
317
|
+
schema: schemaName,
|
|
318
|
+
artifacts,
|
|
319
|
+
taskProgress: taskProgress ?? void 0,
|
|
320
|
+
createdAt,
|
|
321
|
+
applied
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function parseChanges(openspecDir, schemas) {
|
|
325
|
+
const changesDir = path4.join(openspecDir, "changes");
|
|
326
|
+
const changes = [];
|
|
327
|
+
try {
|
|
328
|
+
const entries = await fs5.readdir(changesDir, { withFileTypes: true });
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
if (!entry.isDirectory()) continue;
|
|
331
|
+
if (entry.name === "archive") {
|
|
332
|
+
const archiveDir = path4.join(changesDir, "archive");
|
|
333
|
+
try {
|
|
334
|
+
const archiveEntries = await fs5.readdir(archiveDir, { withFileTypes: true });
|
|
335
|
+
for (const ae of archiveEntries) {
|
|
336
|
+
if (!ae.isDirectory()) continue;
|
|
337
|
+
const change2 = await parseOneChange(
|
|
338
|
+
path4.join(archiveDir, ae.name),
|
|
339
|
+
ae.name,
|
|
340
|
+
schemas,
|
|
341
|
+
true
|
|
342
|
+
);
|
|
343
|
+
changes.push(change2);
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const change = await parseOneChange(
|
|
350
|
+
path4.join(changesDir, entry.name),
|
|
351
|
+
entry.name,
|
|
352
|
+
schemas,
|
|
353
|
+
false
|
|
354
|
+
);
|
|
355
|
+
changes.push(change);
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
return changes;
|
|
360
|
+
}
|
|
361
|
+
async function parseChangeDetail(openspecDir, name, schemas) {
|
|
362
|
+
const changesDir = path4.join(openspecDir, "changes");
|
|
363
|
+
let changeDir = path4.join(changesDir, name);
|
|
364
|
+
if (!await fileExists(changeDir)) {
|
|
365
|
+
changeDir = path4.join(changesDir, "archive", name);
|
|
366
|
+
if (!await fileExists(changeDir)) return null;
|
|
367
|
+
}
|
|
368
|
+
const isArchived = changeDir.includes("/archive/");
|
|
369
|
+
const change = await parseOneChange(changeDir, name, schemas, isArchived);
|
|
370
|
+
const artifactContents = {};
|
|
371
|
+
for (const artifact of change.artifacts) {
|
|
372
|
+
if (artifact.filePath && artifact.status === "done") {
|
|
373
|
+
try {
|
|
374
|
+
artifactContents[artifact.id] = await fs5.readFile(artifact.filePath, "utf-8");
|
|
375
|
+
} catch {
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const specFiles = [];
|
|
380
|
+
const specsDir = path4.join(changeDir, "specs");
|
|
381
|
+
try {
|
|
382
|
+
const specEntries = await fs5.readdir(specsDir, { withFileTypes: true });
|
|
383
|
+
for (const entry of specEntries) {
|
|
384
|
+
if (!entry.isDirectory()) continue;
|
|
385
|
+
const specPath = path4.join(specsDir, entry.name, "spec.md");
|
|
386
|
+
try {
|
|
387
|
+
const content = await fs5.readFile(specPath, "utf-8");
|
|
388
|
+
specFiles.push({ capability: entry.name, filePath: specPath, content });
|
|
389
|
+
} catch {
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
}
|
|
394
|
+
return { ...change, artifactContents, specFiles };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/server/parser/index.ts
|
|
398
|
+
async function loadProjectData(openspecDir) {
|
|
399
|
+
const [config, schemas] = await Promise.all([
|
|
400
|
+
parseConfig(openspecDir),
|
|
401
|
+
parseSchemas(openspecDir)
|
|
402
|
+
]);
|
|
403
|
+
config.availableSchemas = schemas;
|
|
404
|
+
const changes = await parseChanges(openspecDir, schemas);
|
|
405
|
+
return { config, schemas, changes };
|
|
406
|
+
}
|
|
407
|
+
async function loadChangeDetail(openspecDir, name, schemas) {
|
|
408
|
+
return parseChangeDetail(openspecDir, name, schemas);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/server/routes/changes.ts
|
|
412
|
+
function createChangesRouter(getData, getOpenspecDir) {
|
|
413
|
+
const router = Router2();
|
|
414
|
+
router.get("/", (_req, res) => {
|
|
415
|
+
res.json(getData().changes);
|
|
416
|
+
});
|
|
417
|
+
router.get("/:name", async (req, res) => {
|
|
418
|
+
const { name } = req.params;
|
|
419
|
+
const detail = await loadChangeDetail(getOpenspecDir(), name, getData().schemas);
|
|
420
|
+
if (!detail) {
|
|
421
|
+
res.status(404).json({ error: "Change not found" });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
res.json(detail);
|
|
425
|
+
});
|
|
426
|
+
router.post("/batch-archive", async (req, res) => {
|
|
427
|
+
const { names } = req.body;
|
|
428
|
+
if (!Array.isArray(names) || names.length === 0) {
|
|
429
|
+
res.status(400).json({ error: "names must be a non-empty array" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const openspecDir = getOpenspecDir();
|
|
433
|
+
const changesDir = path5.join(openspecDir, "changes");
|
|
434
|
+
const archiveDir = path5.join(changesDir, "archive");
|
|
435
|
+
const results = [];
|
|
436
|
+
for (const name of names) {
|
|
437
|
+
const changeDir = path5.join(changesDir, name);
|
|
438
|
+
const archiveTarget = path5.join(archiveDir, name);
|
|
439
|
+
try {
|
|
440
|
+
if (!fs6.existsSync(changeDir)) {
|
|
441
|
+
results.push({ name, success: false, error: "Change directory not found" });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (fs6.existsSync(archiveTarget)) {
|
|
445
|
+
results.push({ name, success: false, error: "Already archived" });
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
fs6.mkdirSync(archiveDir, { recursive: true });
|
|
449
|
+
fs6.renameSync(changeDir, archiveTarget);
|
|
450
|
+
results.push({ name, success: true });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
results.push({ name, success: false, error: String(err) });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const allSuccess = results.every((r) => r.success);
|
|
456
|
+
res.status(allSuccess ? 200 : 207).json({ results });
|
|
457
|
+
});
|
|
458
|
+
router.post("/batch-unarchive", async (req, res) => {
|
|
459
|
+
const { names } = req.body;
|
|
460
|
+
if (!Array.isArray(names) || names.length === 0) {
|
|
461
|
+
res.status(400).json({ error: "names must be a non-empty array" });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const openspecDir = getOpenspecDir();
|
|
465
|
+
const changesDir = path5.join(openspecDir, "changes");
|
|
466
|
+
const archiveDir = path5.join(changesDir, "archive");
|
|
467
|
+
const results = [];
|
|
468
|
+
for (const name of names) {
|
|
469
|
+
const archiveSource = path5.join(archiveDir, name);
|
|
470
|
+
const changeTarget = path5.join(changesDir, name);
|
|
471
|
+
try {
|
|
472
|
+
if (!fs6.existsSync(archiveSource)) {
|
|
473
|
+
results.push({ name, success: false, error: "Archived change not found" });
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (fs6.existsSync(changeTarget)) {
|
|
477
|
+
results.push({ name, success: false, error: "Change already exists" });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
fs6.renameSync(archiveSource, changeTarget);
|
|
481
|
+
results.push({ name, success: true });
|
|
482
|
+
} catch (err) {
|
|
483
|
+
results.push({ name, success: false, error: String(err) });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const allSuccess = results.every((r) => r.success);
|
|
487
|
+
res.status(allSuccess ? 200 : 207).json({ results });
|
|
488
|
+
});
|
|
489
|
+
return router;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/server/routes/schemas.ts
|
|
493
|
+
import { Router as Router3 } from "express";
|
|
494
|
+
function createSchemasRouter(getData) {
|
|
495
|
+
const router = Router3();
|
|
496
|
+
router.get("/", (_req, res) => {
|
|
497
|
+
res.json(getData().schemas);
|
|
498
|
+
});
|
|
499
|
+
return router;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// src/server/routes/skills.ts
|
|
503
|
+
import { Router as Router4 } from "express";
|
|
504
|
+
import fs7 from "fs";
|
|
505
|
+
import path6 from "path";
|
|
506
|
+
import matter from "gray-matter";
|
|
507
|
+
function createSkillsRouter(getProjectDir) {
|
|
508
|
+
const router = Router4();
|
|
509
|
+
router.get("/", (_req, res) => {
|
|
510
|
+
const opsxDir = path6.join(getProjectDir(), ".claude", "commands", "opsx");
|
|
511
|
+
const skills = [];
|
|
512
|
+
try {
|
|
513
|
+
const entries = fs7.readdirSync(opsxDir);
|
|
514
|
+
for (const entry of entries) {
|
|
515
|
+
if (!entry.endsWith(".md")) continue;
|
|
516
|
+
const filePath = path6.join(opsxDir, entry);
|
|
517
|
+
try {
|
|
518
|
+
const raw = fs7.readFileSync(filePath, "utf-8");
|
|
519
|
+
const { data } = matter(raw);
|
|
520
|
+
skills.push({
|
|
521
|
+
id: entry.replace(".md", ""),
|
|
522
|
+
name: data.name || entry.replace(".md", ""),
|
|
523
|
+
description: data.description || "",
|
|
524
|
+
category: data.category || "",
|
|
525
|
+
tags: data.tags || []
|
|
526
|
+
});
|
|
527
|
+
} catch {
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
}
|
|
532
|
+
res.json(skills);
|
|
533
|
+
});
|
|
534
|
+
return router;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/server/routes/execution.ts
|
|
538
|
+
import { Router as Router5 } from "express";
|
|
539
|
+
import fs8 from "fs";
|
|
540
|
+
import path7 from "path";
|
|
541
|
+
var IDLE_STATE = {
|
|
542
|
+
skill: "",
|
|
543
|
+
change: "",
|
|
544
|
+
status: "idle",
|
|
545
|
+
phase: "creating_artifact",
|
|
546
|
+
currentTask: 0,
|
|
547
|
+
totalTasks: 0,
|
|
548
|
+
currentArtifact: null,
|
|
549
|
+
startedAt: "",
|
|
550
|
+
updatedAt: ""
|
|
551
|
+
};
|
|
552
|
+
function createExecutionRouter(getOpenspecDir, executor) {
|
|
553
|
+
const router = Router5();
|
|
554
|
+
router.get("/", (_req, res) => {
|
|
555
|
+
const execPath = path7.join(getOpenspecDir(), "execution.json");
|
|
556
|
+
try {
|
|
557
|
+
const raw = fs8.readFileSync(execPath, "utf-8");
|
|
558
|
+
const state = JSON.parse(raw);
|
|
559
|
+
res.json(state);
|
|
560
|
+
} catch {
|
|
561
|
+
res.json(IDLE_STATE);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
router.get("/log", (_req, res) => {
|
|
565
|
+
const logPath = path7.join(getOpenspecDir(), "execution.log");
|
|
566
|
+
try {
|
|
567
|
+
const raw = fs8.readFileSync(logPath, "utf-8");
|
|
568
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
569
|
+
const entries = lines.map((line) => ({
|
|
570
|
+
line,
|
|
571
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
572
|
+
}));
|
|
573
|
+
res.json(entries);
|
|
574
|
+
} catch {
|
|
575
|
+
res.json([]);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
router.post("/start", async (req, res) => {
|
|
579
|
+
if (!executor) {
|
|
580
|
+
res.status(501).json({ error: "Executor not available" });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const { skill, change, note } = req.body;
|
|
584
|
+
if (!skill || !change) {
|
|
585
|
+
res.status(400).json({ error: "skill and change are required" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (executor.getStatus().isRunning) {
|
|
589
|
+
res.status(409).json({ error: "Another skill is already running" });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
executor.execute(skill, change, note).catch(() => {
|
|
593
|
+
});
|
|
594
|
+
res.json({ status: "started", skill, change });
|
|
595
|
+
});
|
|
596
|
+
router.post("/cancel", (_req, res) => {
|
|
597
|
+
if (!executor) {
|
|
598
|
+
res.status(501).json({ error: "Executor not available" });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const cancelled = executor.cancel();
|
|
602
|
+
if (cancelled) {
|
|
603
|
+
res.json({ status: "cancelled" });
|
|
604
|
+
} else {
|
|
605
|
+
res.status(404).json({ error: "No running skill to cancel" });
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
router.post("/input", (req, res) => {
|
|
609
|
+
if (!executor) {
|
|
610
|
+
res.status(501).json({ error: "Executor not available" });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const { input } = req.body;
|
|
614
|
+
if (!input || typeof input !== "string") {
|
|
615
|
+
res.status(400).json({ success: false, error: "input is required" });
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const result = executor.sendInput(input);
|
|
619
|
+
if (result.success) {
|
|
620
|
+
res.json({ success: true });
|
|
621
|
+
} else {
|
|
622
|
+
res.status(400).json({ success: false, error: result.error });
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
return router;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/server/routes/projects.ts
|
|
629
|
+
import { Router as Router6 } from "express";
|
|
630
|
+
import fs9 from "fs/promises";
|
|
631
|
+
import path8 from "path";
|
|
632
|
+
import yaml4 from "js-yaml";
|
|
633
|
+
var SCAN_DIRS = ["/home/zw/project"];
|
|
634
|
+
async function discoverProjects(scanDirs) {
|
|
635
|
+
const projects = [];
|
|
636
|
+
for (const scanDir of scanDirs) {
|
|
637
|
+
let entries;
|
|
638
|
+
try {
|
|
639
|
+
entries = await fs9.readdir(scanDir);
|
|
640
|
+
} catch {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
for (const entry of entries) {
|
|
644
|
+
const projectPath = path8.join(scanDir, entry);
|
|
645
|
+
const openspecPath = path8.join(projectPath, "openspec");
|
|
646
|
+
try {
|
|
647
|
+
const stat = await fs9.stat(projectPath);
|
|
648
|
+
if (!stat.isDirectory()) continue;
|
|
649
|
+
const openspecStat = await fs9.stat(openspecPath);
|
|
650
|
+
if (!openspecStat.isDirectory()) continue;
|
|
651
|
+
const name = await getProjectName(openspecPath, entry);
|
|
652
|
+
projects.push({ name, path: projectPath, available: true });
|
|
653
|
+
} catch {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return projects;
|
|
659
|
+
}
|
|
660
|
+
async function getProjectName(openspecDir, fallback) {
|
|
661
|
+
try {
|
|
662
|
+
const configPath = path8.join(openspecDir, "config.yaml");
|
|
663
|
+
const content = await fs9.readFile(configPath, "utf-8");
|
|
664
|
+
const raw = yaml4.load(content);
|
|
665
|
+
return raw?.project?.name || fallback;
|
|
666
|
+
} catch {
|
|
667
|
+
return fallback;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async function validateProjectPath(projectPath) {
|
|
671
|
+
try {
|
|
672
|
+
const openspecPath = path8.join(projectPath, "openspec");
|
|
673
|
+
const stat = await fs9.stat(openspecPath);
|
|
674
|
+
return stat.isDirectory();
|
|
675
|
+
} catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function createProjectsRouter(getOpenspecDir, switchProject) {
|
|
680
|
+
const router = Router6();
|
|
681
|
+
router.get("/", async (_req, res) => {
|
|
682
|
+
const projects = await discoverProjects(SCAN_DIRS);
|
|
683
|
+
const openspecDir = getOpenspecDir();
|
|
684
|
+
const currentProjectPath = path8.resolve(openspecDir, "..");
|
|
685
|
+
const currentInList = projects.some((p) => p.path === currentProjectPath);
|
|
686
|
+
if (!currentInList) {
|
|
687
|
+
try {
|
|
688
|
+
await fs9.access(openspecDir);
|
|
689
|
+
const name = await getProjectName(openspecDir, path8.basename(currentProjectPath));
|
|
690
|
+
projects.unshift({ name, path: currentProjectPath, available: true });
|
|
691
|
+
} catch {
|
|
692
|
+
projects.unshift({ name: path8.basename(currentProjectPath), path: currentProjectPath, available: false });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
res.json(projects);
|
|
696
|
+
});
|
|
697
|
+
router.post("/switch", async (req, res) => {
|
|
698
|
+
const { path: projectPath } = req.body;
|
|
699
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
700
|
+
res.status(400).json({ error: "path is required" });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const valid = await validateProjectPath(projectPath);
|
|
704
|
+
if (!valid) {
|
|
705
|
+
res.status(400).json({ error: "Invalid project path: no openspec/ directory found" });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
await switchProject(projectPath);
|
|
710
|
+
res.json({ status: "ok", path: projectPath });
|
|
711
|
+
} catch (err) {
|
|
712
|
+
res.status(500).json({ error: String(err) });
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
return router;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/server/watcher.ts
|
|
719
|
+
import chokidar from "chokidar";
|
|
720
|
+
import fs10 from "fs";
|
|
721
|
+
import path9 from "path";
|
|
722
|
+
function parseTaskProgress2(line) {
|
|
723
|
+
const patterns = [
|
|
724
|
+
/[Tt]ask\s+(\d+)\/(\d+)/,
|
|
725
|
+
/(\d+)\/(\d+)/,
|
|
726
|
+
/[Tt]ask\s+(\d+)\s+of\s+(\d+)/,
|
|
727
|
+
/\[(\d+)\/(\d+)\]/
|
|
728
|
+
];
|
|
729
|
+
for (const pattern of patterns) {
|
|
730
|
+
const match = line.match(pattern);
|
|
731
|
+
if (match) {
|
|
732
|
+
return { current: parseInt(match[1], 10), total: parseInt(match[2], 10) };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
function createWatcher(options) {
|
|
738
|
+
const { onChange, debounceMs = 500, onSkillProgress, onSkillLog } = options;
|
|
739
|
+
let timeout = null;
|
|
740
|
+
let lastLogSize = 0;
|
|
741
|
+
let lastState = null;
|
|
742
|
+
let currentDir = options.openspecDir;
|
|
743
|
+
const debouncedChange = () => {
|
|
744
|
+
if (timeout) clearTimeout(timeout);
|
|
745
|
+
timeout = setTimeout(onChange, debounceMs);
|
|
746
|
+
};
|
|
747
|
+
const isExecutionFile = (filePath) => {
|
|
748
|
+
return filePath.endsWith("execution.json") || filePath.endsWith("execution.log");
|
|
749
|
+
};
|
|
750
|
+
const handleExecutionChange = (filePath) => {
|
|
751
|
+
if (filePath.endsWith("execution.json") && onSkillProgress) {
|
|
752
|
+
try {
|
|
753
|
+
const raw = fs10.readFileSync(filePath, "utf-8");
|
|
754
|
+
const state = JSON.parse(raw);
|
|
755
|
+
onSkillProgress(state);
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (filePath.endsWith("execution.log") && onSkillLog) {
|
|
760
|
+
try {
|
|
761
|
+
const stat = fs10.statSync(filePath);
|
|
762
|
+
if (stat.size > lastLogSize) {
|
|
763
|
+
const raw = fs10.readFileSync(filePath, "utf-8");
|
|
764
|
+
const newContent = raw.substring(lastLogSize);
|
|
765
|
+
lastLogSize = stat.size;
|
|
766
|
+
const lines = newContent.split("\n").filter(Boolean);
|
|
767
|
+
for (const line of lines) {
|
|
768
|
+
onSkillLog(line);
|
|
769
|
+
if (onSkillProgress && lastState && lastState.status === "running") {
|
|
770
|
+
const progress = parseTaskProgress2(line);
|
|
771
|
+
if (progress && (progress.current !== lastState.currentTask || progress.total !== lastState.totalTasks)) {
|
|
772
|
+
const updatedState = {
|
|
773
|
+
...lastState,
|
|
774
|
+
currentTask: progress.current,
|
|
775
|
+
totalTasks: progress.total,
|
|
776
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
777
|
+
};
|
|
778
|
+
lastState = updatedState;
|
|
779
|
+
const execPath = path9.join(currentDir, "execution.json");
|
|
780
|
+
fs10.writeFileSync(execPath, JSON.stringify(updatedState, null, 2) + "\n");
|
|
781
|
+
onSkillProgress(updatedState);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
const setupHandlers = (watcher2) => {
|
|
791
|
+
watcher2.on("add", (filePath) => {
|
|
792
|
+
if (isExecutionFile(filePath)) {
|
|
793
|
+
handleExecutionChange(filePath);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (isRelevantFile(filePath)) debouncedChange();
|
|
797
|
+
});
|
|
798
|
+
watcher2.on("change", (filePath) => {
|
|
799
|
+
if (isExecutionFile(filePath)) {
|
|
800
|
+
handleExecutionChange(filePath);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (isRelevantFile(filePath)) debouncedChange();
|
|
804
|
+
});
|
|
805
|
+
watcher2.on("unlink", (filePath) => {
|
|
806
|
+
if (isExecutionFile(filePath)) return;
|
|
807
|
+
if (isRelevantFile(filePath)) debouncedChange();
|
|
808
|
+
});
|
|
809
|
+
};
|
|
810
|
+
let watcher = chokidar.watch([
|
|
811
|
+
currentDir,
|
|
812
|
+
path9.join(currentDir, "execution.json"),
|
|
813
|
+
path9.join(currentDir, "execution.log")
|
|
814
|
+
], {
|
|
815
|
+
ignored: [
|
|
816
|
+
/(^|[\/\\])\../,
|
|
817
|
+
"**/node_modules/**"
|
|
818
|
+
],
|
|
819
|
+
persistent: true,
|
|
820
|
+
ignoreInitial: true
|
|
821
|
+
});
|
|
822
|
+
setupHandlers(watcher);
|
|
823
|
+
return {
|
|
824
|
+
close: () => {
|
|
825
|
+
if (timeout) clearTimeout(timeout);
|
|
826
|
+
return watcher.close();
|
|
827
|
+
},
|
|
828
|
+
async switchDir(newOpenspecDir) {
|
|
829
|
+
if (timeout) clearTimeout(timeout);
|
|
830
|
+
lastLogSize = 0;
|
|
831
|
+
lastState = null;
|
|
832
|
+
await watcher.close();
|
|
833
|
+
currentDir = newOpenspecDir;
|
|
834
|
+
watcher = chokidar.watch([
|
|
835
|
+
currentDir,
|
|
836
|
+
path9.join(currentDir, "execution.json"),
|
|
837
|
+
path9.join(currentDir, "execution.log")
|
|
838
|
+
], {
|
|
839
|
+
ignored: [
|
|
840
|
+
/(^|[\/\\])\../,
|
|
841
|
+
"**/node_modules/**"
|
|
842
|
+
],
|
|
843
|
+
persistent: true,
|
|
844
|
+
ignoreInitial: true
|
|
845
|
+
});
|
|
846
|
+
setupHandlers(watcher);
|
|
847
|
+
console.log(`Watcher switched to: ${currentDir}`);
|
|
848
|
+
},
|
|
849
|
+
getCurrentDir() {
|
|
850
|
+
return currentDir;
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function isRelevantFile(filePath) {
|
|
855
|
+
const ext = path9.extname(filePath);
|
|
856
|
+
return ext === ".md" || ext === ".yaml" || ext === ".yml";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/server/ws.ts
|
|
860
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
861
|
+
function createWebSocketServer(server) {
|
|
862
|
+
const wss = new WebSocketServer({ server, path: "/ws/changes" });
|
|
863
|
+
const broadcast = (data) => {
|
|
864
|
+
const message = JSON.stringify(data);
|
|
865
|
+
wss.clients.forEach((client) => {
|
|
866
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
867
|
+
client.send(message);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
};
|
|
871
|
+
return { wss, broadcast };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/server/executor.ts
|
|
875
|
+
import { spawn as spawn2 } from "child_process";
|
|
876
|
+
import fs11 from "fs";
|
|
877
|
+
import path10 from "path";
|
|
878
|
+
var IDLE_STATE2 = {
|
|
879
|
+
skill: "",
|
|
880
|
+
change: "",
|
|
881
|
+
status: "idle",
|
|
882
|
+
phase: "creating_artifact",
|
|
883
|
+
currentTask: 0,
|
|
884
|
+
totalTasks: 0,
|
|
885
|
+
currentArtifact: null,
|
|
886
|
+
startedAt: "",
|
|
887
|
+
updatedAt: ""
|
|
888
|
+
};
|
|
889
|
+
var SkillExecutor = class {
|
|
890
|
+
openspecDir;
|
|
891
|
+
projectDir;
|
|
892
|
+
broadcast;
|
|
893
|
+
currentProcess = null;
|
|
894
|
+
currentStdin = null;
|
|
895
|
+
currentSkill = "";
|
|
896
|
+
currentChange = "";
|
|
897
|
+
constructor(options) {
|
|
898
|
+
this.openspecDir = options.openspecDir;
|
|
899
|
+
this.projectDir = options.projectDir;
|
|
900
|
+
this.broadcast = options.broadcast;
|
|
901
|
+
}
|
|
902
|
+
async execute(skill, change, note) {
|
|
903
|
+
if (this.currentProcess) {
|
|
904
|
+
throw new Error("Another skill is already running");
|
|
905
|
+
}
|
|
906
|
+
this.currentSkill = skill;
|
|
907
|
+
this.currentChange = change;
|
|
908
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
909
|
+
const state = {
|
|
910
|
+
skill,
|
|
911
|
+
change,
|
|
912
|
+
status: "running",
|
|
913
|
+
phase: skill === "apply" ? "implementing" : "creating_artifact",
|
|
914
|
+
currentTask: 0,
|
|
915
|
+
totalTasks: 0,
|
|
916
|
+
currentArtifact: null,
|
|
917
|
+
startedAt: now,
|
|
918
|
+
updatedAt: now
|
|
919
|
+
};
|
|
920
|
+
this.writeState(state);
|
|
921
|
+
this.clearLog();
|
|
922
|
+
this.appendLog(`Starting /opsx:${skill} ${change}`);
|
|
923
|
+
this.broadcast({ type: "skill_progress", data: state });
|
|
924
|
+
this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] Starting /opsx:${skill} ${change}`, timestamp: now } });
|
|
925
|
+
let prompt = `/opsx:${skill} ${change}`;
|
|
926
|
+
if (note) {
|
|
927
|
+
prompt += `
|
|
928
|
+
|
|
929
|
+
\u7528\u6237\u5907\u6CE8\uFF1A${note}`;
|
|
930
|
+
}
|
|
931
|
+
return new Promise((resolve, reject) => {
|
|
932
|
+
const proc = spawn2("claude", [
|
|
933
|
+
"-p",
|
|
934
|
+
prompt,
|
|
935
|
+
"--output-format",
|
|
936
|
+
"stream-json",
|
|
937
|
+
"--verbose",
|
|
938
|
+
"--allowedTools",
|
|
939
|
+
"Bash,Read,Write,Edit,MCP"
|
|
940
|
+
], {
|
|
941
|
+
cwd: this.projectDir,
|
|
942
|
+
env: { ...process.env },
|
|
943
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
944
|
+
});
|
|
945
|
+
this.currentProcess = proc;
|
|
946
|
+
this.currentStdin = proc.stdin;
|
|
947
|
+
let buffer = "";
|
|
948
|
+
proc.stdout.on("data", (chunk) => {
|
|
949
|
+
buffer += chunk.toString();
|
|
950
|
+
const lines = buffer.split("\n");
|
|
951
|
+
buffer = lines.pop() || "";
|
|
952
|
+
for (const line of lines) {
|
|
953
|
+
if (!line.trim()) continue;
|
|
954
|
+
try {
|
|
955
|
+
const event = JSON.parse(line);
|
|
956
|
+
this.handleStreamEvent(event);
|
|
957
|
+
} catch {
|
|
958
|
+
this.appendLog(line);
|
|
959
|
+
this.broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
proc.stderr.on("data", (chunk) => {
|
|
964
|
+
const text = chunk.toString();
|
|
965
|
+
this.appendLog(text);
|
|
966
|
+
this.broadcast({ type: "skill_log", data: { line: text.trim(), timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
967
|
+
});
|
|
968
|
+
proc.on("close", (code) => {
|
|
969
|
+
this.currentProcess = null;
|
|
970
|
+
this.currentStdin = null;
|
|
971
|
+
const currentState = this.readState();
|
|
972
|
+
if (code === 0 && currentState.status === "running") {
|
|
973
|
+
const completed = {
|
|
974
|
+
...currentState,
|
|
975
|
+
status: "completed",
|
|
976
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
977
|
+
};
|
|
978
|
+
this.writeState(completed);
|
|
979
|
+
this.appendLog(`\u2713 /opsx:${skill} completed`);
|
|
980
|
+
this.broadcast({ type: "skill_complete", data: completed });
|
|
981
|
+
this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] \u2713 /opsx:${skill} completed`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
982
|
+
this.broadcast({ type: "refresh" });
|
|
983
|
+
} else if (code !== 0) {
|
|
984
|
+
const failed = {
|
|
985
|
+
...currentState,
|
|
986
|
+
status: "failed",
|
|
987
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
988
|
+
};
|
|
989
|
+
this.writeState(failed);
|
|
990
|
+
this.appendLog(`\u2717 /opsx:${skill} failed (exit code ${code})`);
|
|
991
|
+
this.broadcast({ type: "skill_complete", data: failed });
|
|
992
|
+
this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] \u2717 /opsx:${skill} failed (exit code ${code})`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
993
|
+
}
|
|
994
|
+
resolve();
|
|
995
|
+
});
|
|
996
|
+
proc.on("error", (err) => {
|
|
997
|
+
this.currentProcess = null;
|
|
998
|
+
this.currentStdin = null;
|
|
999
|
+
const failed = {
|
|
1000
|
+
...this.readState(),
|
|
1001
|
+
status: "failed",
|
|
1002
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1003
|
+
};
|
|
1004
|
+
this.writeState(failed);
|
|
1005
|
+
this.appendLog(`\u2717 Failed to start: ${err.message}`);
|
|
1006
|
+
this.broadcast({ type: "skill_complete", data: failed });
|
|
1007
|
+
reject(err);
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
cancel() {
|
|
1012
|
+
if (!this.currentProcess) return false;
|
|
1013
|
+
this.currentProcess.kill("SIGTERM");
|
|
1014
|
+
this.currentProcess = null;
|
|
1015
|
+
this.currentStdin = null;
|
|
1016
|
+
const state = this.readState();
|
|
1017
|
+
const cancelled = {
|
|
1018
|
+
...state,
|
|
1019
|
+
status: "failed",
|
|
1020
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1021
|
+
};
|
|
1022
|
+
this.writeState(cancelled);
|
|
1023
|
+
this.appendLog(`\u2717 /opsx:${this.currentSkill} cancelled by user`);
|
|
1024
|
+
this.broadcast({ type: "skill_complete", data: cancelled });
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
isRunning() {
|
|
1028
|
+
return this.currentProcess !== null;
|
|
1029
|
+
}
|
|
1030
|
+
sendInput(text) {
|
|
1031
|
+
if (!this.currentProcess || !this.currentStdin) {
|
|
1032
|
+
return { success: false, error: "\u8FDB\u7A0B\u672A\u8FD0\u884C" };
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
this.currentStdin.write(text + "\n");
|
|
1036
|
+
this.appendLog(`[\u7528\u6237\u8F93\u5165] ${text}`);
|
|
1037
|
+
this.broadcast({ type: "skill_log", data: { line: `[\u7528\u6237\u8F93\u5165] ${text}`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1038
|
+
return { success: true };
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
return { success: false, error: "\u65E0\u6CD5\u53D1\u9001\u8F93\u5165" };
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
getStatus() {
|
|
1044
|
+
return {
|
|
1045
|
+
isRunning: this.currentProcess !== null,
|
|
1046
|
+
skill: this.currentSkill,
|
|
1047
|
+
change: this.currentChange
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
updateBroadcast(broadcast) {
|
|
1051
|
+
this.broadcast = broadcast;
|
|
1052
|
+
}
|
|
1053
|
+
updateDirs(openspecDir, projectDir) {
|
|
1054
|
+
this.openspecDir = openspecDir;
|
|
1055
|
+
this.projectDir = projectDir;
|
|
1056
|
+
}
|
|
1057
|
+
handleStreamEvent(event) {
|
|
1058
|
+
const type = event.type;
|
|
1059
|
+
if (type === "assistant") {
|
|
1060
|
+
const message = event.message;
|
|
1061
|
+
const content = message?.content;
|
|
1062
|
+
if (Array.isArray(content)) {
|
|
1063
|
+
for (const block of content) {
|
|
1064
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
1065
|
+
const lines = block.text.split("\n").filter(Boolean);
|
|
1066
|
+
for (const line of lines) {
|
|
1067
|
+
this.appendLog(line);
|
|
1068
|
+
this.broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (block.type === "tool_use") {
|
|
1072
|
+
const inputStr = block.input ? JSON.stringify(block.input) : "";
|
|
1073
|
+
const toolLine = `\u2192 ${block.name} ${inputStr}`;
|
|
1074
|
+
this.appendLog(toolLine);
|
|
1075
|
+
this.broadcast({ type: "skill_log", data: { line: toolLine, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (type === "tool_result") {
|
|
1081
|
+
const result = event.result;
|
|
1082
|
+
if (result) {
|
|
1083
|
+
const displayResult = result.length > 300 ? result.substring(0, 300) + "\n ... (\u5DF2\u622A\u65AD)" : result;
|
|
1084
|
+
this.appendLog(displayResult);
|
|
1085
|
+
this.broadcast({ type: "skill_log", data: { line: displayResult, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (type === "result") {
|
|
1089
|
+
const resultText = event.result;
|
|
1090
|
+
if (resultText) {
|
|
1091
|
+
this.appendLog(resultText);
|
|
1092
|
+
this.broadcast({ type: "skill_log", data: { line: resultText, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
timestamp() {
|
|
1097
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false });
|
|
1098
|
+
}
|
|
1099
|
+
statePath() {
|
|
1100
|
+
return path10.join(this.openspecDir, "execution.json");
|
|
1101
|
+
}
|
|
1102
|
+
logPath() {
|
|
1103
|
+
return path10.join(this.openspecDir, "execution.log");
|
|
1104
|
+
}
|
|
1105
|
+
readState() {
|
|
1106
|
+
try {
|
|
1107
|
+
const raw = fs11.readFileSync(this.statePath(), "utf-8");
|
|
1108
|
+
return JSON.parse(raw);
|
|
1109
|
+
} catch {
|
|
1110
|
+
return { ...IDLE_STATE2 };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
writeState(state) {
|
|
1114
|
+
fs11.writeFileSync(this.statePath(), JSON.stringify(state, null, 2) + "\n");
|
|
1115
|
+
}
|
|
1116
|
+
clearLog() {
|
|
1117
|
+
try {
|
|
1118
|
+
fs11.unlinkSync(this.logPath());
|
|
1119
|
+
} catch {
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
appendLog(line) {
|
|
1123
|
+
const timestamp = this.timestamp();
|
|
1124
|
+
const entry = `[${timestamp}] ${line}
|
|
1125
|
+
`;
|
|
1126
|
+
try {
|
|
1127
|
+
fs11.appendFileSync(this.logPath(), entry);
|
|
1128
|
+
} catch {
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
// src/server/index.ts
|
|
1134
|
+
async function createServer(options) {
|
|
1135
|
+
const app = express();
|
|
1136
|
+
app.use(express.json());
|
|
1137
|
+
const projectData = await loadProjectData(options.openspecDir);
|
|
1138
|
+
const projectDir = options.projectDir || path11.resolve(options.openspecDir, "..");
|
|
1139
|
+
let openspecDir = options.openspecDir;
|
|
1140
|
+
app.use("/api/config", createConfigRouter({ getData: () => projectData, getOpenspecDir: () => openspecDir }));
|
|
1141
|
+
app.use("/api/changes", createChangesRouter(() => projectData, () => openspecDir));
|
|
1142
|
+
app.use("/api/schemas", createSchemasRouter(() => projectData));
|
|
1143
|
+
app.use("/api/skills", createSkillsRouter(() => projectDir));
|
|
1144
|
+
app.use("/api/execution", createExecutionRouter(() => openspecDir));
|
|
1145
|
+
app.use("/api/projects", createProjectsRouter(() => openspecDir, async () => {
|
|
1146
|
+
}));
|
|
1147
|
+
if (options.staticDir) {
|
|
1148
|
+
app.use(express.static(options.staticDir));
|
|
1149
|
+
app.get("*", (_req, res) => {
|
|
1150
|
+
res.sendFile(path11.join(options.staticDir, "index.html"));
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
return { app, refreshData: () => Promise.resolve() };
|
|
1154
|
+
}
|
|
1155
|
+
async function startServer(options) {
|
|
1156
|
+
const server = http.createServer();
|
|
1157
|
+
const { broadcast } = createWebSocketServer(server);
|
|
1158
|
+
let currentOpenspecDir = options.openspecDir;
|
|
1159
|
+
let currentProjectDir = options.projectDir || path11.resolve(options.openspecDir, "..");
|
|
1160
|
+
const executor = new SkillExecutor({
|
|
1161
|
+
openspecDir: currentOpenspecDir,
|
|
1162
|
+
projectDir: currentProjectDir,
|
|
1163
|
+
broadcast
|
|
1164
|
+
});
|
|
1165
|
+
const app = express();
|
|
1166
|
+
app.use(express.json());
|
|
1167
|
+
let projectData = await loadProjectData(currentOpenspecDir);
|
|
1168
|
+
const refreshData = async () => {
|
|
1169
|
+
projectData = await loadProjectData(currentOpenspecDir);
|
|
1170
|
+
};
|
|
1171
|
+
app.use("/api/config", createConfigRouter({ getData: () => projectData, getOpenspecDir: () => currentOpenspecDir }));
|
|
1172
|
+
app.use("/api/changes", createChangesRouter(() => projectData, () => currentOpenspecDir));
|
|
1173
|
+
app.use("/api/schemas", createSchemasRouter(() => projectData));
|
|
1174
|
+
app.use("/api/skills", createSkillsRouter(() => currentProjectDir));
|
|
1175
|
+
app.use("/api/execution", createExecutionRouter(() => currentOpenspecDir, executor));
|
|
1176
|
+
const switchProject = async (newProjectPath) => {
|
|
1177
|
+
const oldPath = currentProjectDir;
|
|
1178
|
+
currentProjectDir = newProjectPath;
|
|
1179
|
+
currentOpenspecDir = path11.join(newProjectPath, "openspec");
|
|
1180
|
+
console.log(`Switching project from ${oldPath} to ${newProjectPath}`);
|
|
1181
|
+
executor.updateDirs(currentOpenspecDir, currentProjectDir);
|
|
1182
|
+
await watcher.switchDir(currentOpenspecDir);
|
|
1183
|
+
await refreshData();
|
|
1184
|
+
broadcast({ type: "refresh" });
|
|
1185
|
+
};
|
|
1186
|
+
app.use("/api/projects", createProjectsRouter(() => currentOpenspecDir, switchProject));
|
|
1187
|
+
if (options.staticDir) {
|
|
1188
|
+
app.use(express.static(options.staticDir));
|
|
1189
|
+
app.get("*", (_req, res) => {
|
|
1190
|
+
res.sendFile(path11.join(options.staticDir, "index.html"));
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
server.on("request", app);
|
|
1194
|
+
const watcher = createWatcher({
|
|
1195
|
+
openspecDir: currentOpenspecDir,
|
|
1196
|
+
onChange: async () => {
|
|
1197
|
+
await refreshData().catch((err) => console.error("Failed to refresh project data:", err));
|
|
1198
|
+
broadcast({ type: "refresh" });
|
|
1199
|
+
},
|
|
1200
|
+
onSkillProgress: (state) => {
|
|
1201
|
+
if (state.status === "completed" || state.status === "failed") {
|
|
1202
|
+
broadcast({ type: "skill_complete", data: state });
|
|
1203
|
+
} else {
|
|
1204
|
+
broadcast({ type: "skill_progress", data: state });
|
|
1205
|
+
}
|
|
1206
|
+
},
|
|
1207
|
+
onSkillLog: (line) => {
|
|
1208
|
+
broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
server.listen(options.port, () => {
|
|
1212
|
+
console.log(`OpenSpec Dashboard running at http://localhost:${options.port}`);
|
|
1213
|
+
});
|
|
1214
|
+
const shutdown = async () => {
|
|
1215
|
+
await watcher.close();
|
|
1216
|
+
if (executor.getStatus().isRunning) {
|
|
1217
|
+
executor.cancel();
|
|
1218
|
+
}
|
|
1219
|
+
server.close();
|
|
1220
|
+
};
|
|
1221
|
+
return { server, shutdown };
|
|
1222
|
+
}
|
|
1223
|
+
if (process.argv[1]?.includes("server/index")) {
|
|
1224
|
+
const port = parseInt(process.env.PORT || "3456", 10);
|
|
1225
|
+
const openspecDir = path11.resolve(process.cwd(), "openspec");
|
|
1226
|
+
startServer({ openspecDir, port }).catch(console.error);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
export {
|
|
1230
|
+
createServer,
|
|
1231
|
+
startServer
|
|
1232
|
+
};
|