openspec-dashboard 0.1.0 → 0.1.1
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.en.md +50 -0
- package/README.md +33 -92
- package/dist/bin/cli.js +41 -10
- package/dist/{chunk-YBLVDJ3Y.js → chunk-JYWT57PM.js} +247 -12
- package/dist/client/assets/index-DFTiJmiZ.css +1 -0
- package/dist/client/assets/index-DMwrcLii.js +322 -0
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +1 -1
- package/package.json +4 -2
- package/dist/client/assets/index-LwI8lMI7.js +0 -97
- package/dist/client/assets/index-keJ4Pk8f.css +0 -1
package/README.en.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# OpenSpec Dashboard
|
|
2
|
+
|
|
3
|
+
[中文](./README.md) | **English**
|
|
4
|
+
|
|
5
|
+
> A zero-config visual dashboard for OpenSpec SDD — track the full spec-driven development workflow in your browser with a single command, with built-in visual skill execution.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/openspec-dashboard)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
|
|
11
|
+
## Quick Install
|
|
12
|
+
|
|
13
|
+
`npm i -g openspec-dashboard`.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Three-column Kanban board** — In Progress / Ready / Archived, see the stage of every change at a glance
|
|
18
|
+
- **Artifact visualization** — Live rendering of `proposal.md` / `design.md` / `tasks.md`, with automatic task completion stats
|
|
19
|
+
- **One-click skill execution** — Smart recommendations of `continue` / `apply` / `archive` based on change status, click to run with real-time streaming logs
|
|
20
|
+
- **Hot reload** — `chokidar` + WebSocket, instant refresh on save, no manual reload needed
|
|
21
|
+
- **New project wizard** — Three-step form to generate `openspec/config.yaml`, supports Java / Node / Python / Go
|
|
22
|
+
|
|
23
|
+
## CLI Options
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
openspec-dashboard [options]
|
|
27
|
+
|
|
28
|
+
-p, --port <port> Server port (default 3456)
|
|
29
|
+
-d, --dir <dir> Specify the openspec directory (default: auto-discover up 5 levels)
|
|
30
|
+
--no-open Do not auto-open the browser on startup
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Directory Convention
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
your-project/
|
|
37
|
+
└── openspec/
|
|
38
|
+
├── config.yaml
|
|
39
|
+
├── schemas/<name>/schema.yaml
|
|
40
|
+
└── changes/<change-name>/
|
|
41
|
+
├── .openspec.yaml
|
|
42
|
+
├── proposal.md
|
|
43
|
+
├── design.md
|
|
44
|
+
├── tasks.md
|
|
45
|
+
└── specs/<capability>/spec.md
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,109 +1,50 @@
|
|
|
1
1
|
# OpenSpec Dashboard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**中文** | [English](./README.en.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> 零配置的 OpenSpec SDD 可视化看板 —— 一个命令 即可在浏览器中追踪规格驱动开发的全流程,提供可视化操作skill功能。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- 支持变更类型识别:`feat-*`、`fix-*`、`hotfix-*`
|
|
11
|
-
- 产物状态追踪:proposal → specs → design → tasks
|
|
7
|
+
[](https://www.npmjs.com/package/openspec-dashboard)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](./LICENSE)
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
- Markdown 产物内容实时渲染
|
|
15
|
-
- tasks.md checkbox 完成度统计
|
|
16
|
-
- 相关 spec 文件列表展示
|
|
11
|
+
## 快速安装
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
- 三步式表单引导:基础信息 → 工作流选择 → 生成确认
|
|
20
|
-
- 支持多种技术栈:Java / Node / Python / Go
|
|
21
|
-
- 内置流程模板:标准流程 / 轻量流程 / Hotfix 流程
|
|
22
|
-
- 一键生成项目配置文件
|
|
13
|
+
`npm i -g openspec-dashboard`。
|
|
23
14
|
|
|
24
|
-
|
|
25
|
-
- 文件系统监听(chokidar)
|
|
26
|
-
- WebSocket 增量推送
|
|
27
|
-
- 热更新无需手动刷新
|
|
15
|
+
## 核心功能
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
17
|
+
- **三列 Kanban 看板** — 进行中 / 待实现 / 已归档,一眼掌握所有变更的阶段分布
|
|
18
|
+
- **产物可视化** — 实时渲染 `proposal.md` / `design.md` / `tasks.md`,自动统计 tasks 完成度
|
|
19
|
+
- **一键技能执行** — 根据变更状态智能推荐 `continue` / `apply` / `archive`,点击即跑,日志实时流式回传
|
|
20
|
+
- **热更新** — `chokidar` + WebSocket,编辑保存即刷新,无需手动操作
|
|
21
|
+
- **新项目向导** — 三步表单生成 `openspec/config.yaml`,支持 Java / Node / Python / Go
|
|
33
22
|
|
|
34
|
-
##
|
|
23
|
+
## CLI 参数
|
|
35
24
|
|
|
36
25
|
```bash
|
|
37
|
-
|
|
38
|
-
npm run dev
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
开发服务启动后会自动打开浏览器,展示当前项目的变更看板。
|
|
42
|
-
|
|
43
|
-
## CLI 使用
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
# 默认启动(自动发现 openspec/ 目录)
|
|
47
|
-
npx openspec-dashboard
|
|
48
|
-
|
|
49
|
-
# 自定义端口
|
|
50
|
-
openspec-dashboard --port 3000
|
|
26
|
+
openspec-dashboard [options]
|
|
51
27
|
|
|
52
|
-
|
|
53
|
-
|
|
28
|
+
-p, --port <port> 服务端口(默认 3456)
|
|
29
|
+
-d, --dir <dir> 指定 openspec 目录(默认自动向上查找 5 层)
|
|
30
|
+
--no-open 启动后不自动打开浏览器
|
|
54
31
|
```
|
|
55
32
|
|
|
56
|
-
##
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
|
|
33
|
+
## 目录约定
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
your-project/
|
|
37
|
+
└── openspec/
|
|
38
|
+
├── config.yaml
|
|
39
|
+
├── schemas/<name>/schema.yaml
|
|
40
|
+
└── changes/<change-name>/
|
|
41
|
+
├── .openspec.yaml
|
|
42
|
+
├── proposal.md
|
|
43
|
+
├── design.md
|
|
44
|
+
├── tasks.md
|
|
45
|
+
└── specs/<capability>/spec.md
|
|
61
46
|
```
|
|
62
47
|
|
|
63
|
-
##
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
npm test
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
## 技术栈
|
|
70
|
-
|
|
71
|
-
- **前端框架**: React 18 + Vite
|
|
72
|
-
- **样式**: TailwindCSS 3 + shadcn/ui
|
|
73
|
-
- **后端**: Node.js + Express
|
|
74
|
-
- **文件监听**: chokidar
|
|
75
|
-
- **WebSocket**: ws
|
|
76
|
-
- **Markdown 渲染**: react-markdown + remark-gfm
|
|
77
|
-
|
|
78
|
-
## 项目结构
|
|
79
|
-
|
|
80
|
-
```
|
|
81
|
-
openspec-dashboard/
|
|
82
|
-
├── src/
|
|
83
|
-
│ ├── server/ # 后端服务
|
|
84
|
-
│ │ ├── parser/ # OpenSpec 解析器
|
|
85
|
-
│ │ ├── routes/ # API 路由
|
|
86
|
-
│ │ ├── watcher.ts # 文件监听
|
|
87
|
-
│ │ └── ws.ts # WebSocket
|
|
88
|
-
│ └── client/ # 前端应用
|
|
89
|
-
│ ├── components/ # UI 组件
|
|
90
|
-
│ ├── hooks/ # 自定义 Hooks
|
|
91
|
-
│ └── lib/ # 工具函数
|
|
92
|
-
├── openspec/ # 示例 OpenSpec 配置
|
|
93
|
-
└── tests/ # 测试文件
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
## OpenSpec SDD 流程
|
|
97
|
-
|
|
98
|
-
OpenSpec Dashboard 支持 Spec-Driven Development 流程,通过以下产物管理变更:
|
|
99
|
-
|
|
100
|
-
1. **proposal.md** - 变更提案
|
|
101
|
-
2. **specs/** - 技术规格文档
|
|
102
|
-
3. **design.md** - 设计文档
|
|
103
|
-
4. **tasks.md** - 任务清单
|
|
104
|
-
|
|
105
|
-
变更状态自动判定:
|
|
106
|
-
- **进行中**:有产物但未全部完成
|
|
107
|
-
- **待实现**:所有产物已就绪,等待 apply
|
|
108
|
-
- **已归档**:位于 `archive/` 目录下的变更
|
|
48
|
+
## License
|
|
109
49
|
|
|
50
|
+
MIT
|
package/dist/bin/cli.js
CHANGED
|
@@ -1,24 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
startServer
|
|
4
|
-
} from "../chunk-
|
|
4
|
+
} from "../chunk-JYWT57PM.js";
|
|
5
5
|
|
|
6
6
|
// src/bin/cli.ts
|
|
7
7
|
import { program } from "commander";
|
|
8
|
-
import
|
|
8
|
+
import path2 from "path";
|
|
9
9
|
import fs from "fs";
|
|
10
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
11
|
+
|
|
12
|
+
// src/shared/version.ts
|
|
13
|
+
import { readFileSync } from "fs";
|
|
14
|
+
import path from "path";
|
|
10
15
|
import { fileURLToPath } from "url";
|
|
11
|
-
var
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
var cachedVersion = null;
|
|
17
|
+
function getVersion() {
|
|
18
|
+
if (cachedVersion) return cachedVersion;
|
|
19
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname2 = path.dirname(__filename2);
|
|
21
|
+
const candidates = [
|
|
22
|
+
path.resolve(__dirname2, "../../package.json"),
|
|
23
|
+
path.resolve(__dirname2, "../package.json"),
|
|
24
|
+
path.resolve(__dirname2, "../../../package.json")
|
|
25
|
+
];
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(candidate, "utf-8");
|
|
29
|
+
const pkg = JSON.parse(raw);
|
|
30
|
+
if (pkg.version) {
|
|
31
|
+
cachedVersion = pkg.version;
|
|
32
|
+
return cachedVersion;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
cachedVersion = "0.0.0";
|
|
38
|
+
return cachedVersion;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/bin/cli.ts
|
|
42
|
+
var __filename = fileURLToPath2(import.meta.url);
|
|
43
|
+
var __dirname = path2.dirname(__filename);
|
|
44
|
+
program.name("openspec-dashboard").description("OpenSpec SDD \u672C\u5730\u53EF\u89C6\u5316\u770B\u677F").version(getVersion()).option("-p, --port <port>", "\u670D\u52A1\u7AEF\u53E3", "3456").option("-d, --dir <dir>", "\u6307\u5B9A openspec \u76EE\u5F55\u8DEF\u5F84").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").action(async (opts) => {
|
|
14
45
|
const port = parseInt(opts.port, 10);
|
|
15
46
|
const openspecDir = resolveOpenspecDir(opts.dir);
|
|
16
47
|
if (!openspecDir) {
|
|
17
48
|
console.error("\u9519\u8BEF\uFF1A\u672A\u627E\u5230 openspec/ \u76EE\u5F55\u3002\u8BF7\u5728\u5305\u542B openspec/ \u7684\u9879\u76EE\u4E2D\u8FD0\u884C\uFF0C\u6216\u4F7F\u7528 --dir \u6307\u5B9A\u8DEF\u5F84\u3002");
|
|
18
49
|
process.exit(1);
|
|
19
50
|
}
|
|
20
|
-
const staticDir =
|
|
21
|
-
const { shutdown } = await startServer({ openspecDir, port, staticDir, projectDir:
|
|
51
|
+
const staticDir = path2.resolve(__dirname, "../client");
|
|
52
|
+
const { shutdown } = await startServer({ openspecDir, port, staticDir, projectDir: path2.resolve(openspecDir, "..") });
|
|
22
53
|
if (opts.open !== false) {
|
|
23
54
|
const open = await import("open");
|
|
24
55
|
await open.default(`http://localhost:${port}`);
|
|
@@ -30,17 +61,17 @@ program.name("openspec-dashboard").description("OpenSpec SDD \u672C\u5730\u53EF\
|
|
|
30
61
|
});
|
|
31
62
|
function resolveOpenspecDir(dir) {
|
|
32
63
|
if (dir) {
|
|
33
|
-
const abs =
|
|
64
|
+
const abs = path2.resolve(dir);
|
|
34
65
|
if (fs.existsSync(abs)) return abs;
|
|
35
66
|
return null;
|
|
36
67
|
}
|
|
37
68
|
let current = process.cwd();
|
|
38
69
|
for (let i = 0; i < 5; i++) {
|
|
39
|
-
const candidate =
|
|
70
|
+
const candidate = path2.join(current, "openspec");
|
|
40
71
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
41
72
|
return candidate;
|
|
42
73
|
}
|
|
43
|
-
const parent =
|
|
74
|
+
const parent = path2.dirname(current);
|
|
44
75
|
if (parent === current) break;
|
|
45
76
|
current = parent;
|
|
46
77
|
}
|
|
@@ -166,6 +166,19 @@ async function parseTaskProgressFromFile(filePath) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// src/server/parser/change-parser.ts
|
|
169
|
+
function extractWhyFromProposal(proposalContent) {
|
|
170
|
+
if (!proposalContent) return void 0;
|
|
171
|
+
const match = proposalContent.match(/##\s+Why\s*\n+([^#]+?)(?:\n+[^#]|#|$)/);
|
|
172
|
+
if (!match) return void 0;
|
|
173
|
+
let why = match[1].trim();
|
|
174
|
+
if (!why) return void 0;
|
|
175
|
+
why = why.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
176
|
+
why = why.replace(/\s+/g, " ").trim();
|
|
177
|
+
if (why.length > 100) {
|
|
178
|
+
why = why.slice(0, 97) + "...";
|
|
179
|
+
}
|
|
180
|
+
return why;
|
|
181
|
+
}
|
|
169
182
|
function deriveChangeType(name) {
|
|
170
183
|
if (name.startsWith("feat-")) return "feat";
|
|
171
184
|
if (name.startsWith("fix-")) return "fix";
|
|
@@ -284,7 +297,7 @@ async function parseOneChange(changeDir, name, schemas, isArchived) {
|
|
|
284
297
|
if (!createdAt) {
|
|
285
298
|
try {
|
|
286
299
|
const stats = await fs5.stat(changeDir);
|
|
287
|
-
createdAt = stats.
|
|
300
|
+
createdAt = stats.mtime.toISOString();
|
|
288
301
|
} catch {
|
|
289
302
|
}
|
|
290
303
|
}
|
|
@@ -292,6 +305,14 @@ async function parseOneChange(changeDir, name, schemas, isArchived) {
|
|
|
292
305
|
const hasProposal = await fileExists(path4.join(changeDir, "proposal.md"));
|
|
293
306
|
const hasTasks = await fileExists(path4.join(changeDir, "tasks.md"));
|
|
294
307
|
const taskProgress = await parseTaskProgressFromFile(path4.join(changeDir, "tasks.md"));
|
|
308
|
+
let why;
|
|
309
|
+
if (hasProposal) {
|
|
310
|
+
try {
|
|
311
|
+
const proposalContent = await fs5.readFile(path4.join(changeDir, "proposal.md"), "utf-8");
|
|
312
|
+
why = extractWhyFromProposal(proposalContent);
|
|
313
|
+
} catch {
|
|
314
|
+
}
|
|
315
|
+
}
|
|
295
316
|
const specsDir = path4.join(changeDir, "specs");
|
|
296
317
|
let applied = false;
|
|
297
318
|
try {
|
|
@@ -318,7 +339,8 @@ async function parseOneChange(changeDir, name, schemas, isArchived) {
|
|
|
318
339
|
artifacts,
|
|
319
340
|
taskProgress: taskProgress ?? void 0,
|
|
320
341
|
createdAt,
|
|
321
|
-
applied
|
|
342
|
+
applied,
|
|
343
|
+
why
|
|
322
344
|
};
|
|
323
345
|
}
|
|
324
346
|
async function parseChanges(openspecDir, schemas) {
|
|
@@ -486,6 +508,38 @@ function createChangesRouter(getData, getOpenspecDir) {
|
|
|
486
508
|
const allSuccess = results.every((r) => r.success);
|
|
487
509
|
res.status(allSuccess ? 200 : 207).json({ results });
|
|
488
510
|
});
|
|
511
|
+
router.post("/batch-delete", async (req, res) => {
|
|
512
|
+
const { names } = req.body;
|
|
513
|
+
if (!Array.isArray(names) || names.length === 0) {
|
|
514
|
+
res.status(400).json({ error: "names must be a non-empty array" });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const openspecDir = getOpenspecDir();
|
|
518
|
+
const changesDir = path5.join(openspecDir, "changes");
|
|
519
|
+
const archiveDir = path5.join(changesDir, "archive");
|
|
520
|
+
const results = [];
|
|
521
|
+
for (const name of names) {
|
|
522
|
+
const activeChangeDir = path5.join(changesDir, name);
|
|
523
|
+
const archivedChangeDir = path5.join(archiveDir, name);
|
|
524
|
+
try {
|
|
525
|
+
let dirToDelete = null;
|
|
526
|
+
if (fs6.existsSync(activeChangeDir)) {
|
|
527
|
+
dirToDelete = activeChangeDir;
|
|
528
|
+
} else if (fs6.existsSync(archivedChangeDir)) {
|
|
529
|
+
dirToDelete = archivedChangeDir;
|
|
530
|
+
} else {
|
|
531
|
+
results.push({ name, success: false, error: "Change not found" });
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
fs6.rmSync(dirToDelete, { recursive: true, force: true });
|
|
535
|
+
results.push({ name, success: true });
|
|
536
|
+
} catch (err) {
|
|
537
|
+
results.push({ name, success: false, error: String(err) });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const allSuccess = results.every((r) => r.success);
|
|
541
|
+
res.status(allSuccess ? 200 : 207).json({ results });
|
|
542
|
+
});
|
|
489
543
|
return router;
|
|
490
544
|
}
|
|
491
545
|
|
|
@@ -669,8 +723,7 @@ async function getProjectName(openspecDir, fallback) {
|
|
|
669
723
|
}
|
|
670
724
|
async function validateProjectPath(projectPath) {
|
|
671
725
|
try {
|
|
672
|
-
const
|
|
673
|
-
const stat = await fs9.stat(openspecPath);
|
|
726
|
+
const stat = await fs9.stat(projectPath);
|
|
674
727
|
return stat.isDirectory();
|
|
675
728
|
} catch {
|
|
676
729
|
return false;
|
|
@@ -702,7 +755,7 @@ function createProjectsRouter(getOpenspecDir, switchProject) {
|
|
|
702
755
|
}
|
|
703
756
|
const valid = await validateProjectPath(projectPath);
|
|
704
757
|
if (!valid) {
|
|
705
|
-
res.status(400).json({ error: "Invalid project path:
|
|
758
|
+
res.status(400).json({ error: "Invalid project path: directory does not exist" });
|
|
706
759
|
return;
|
|
707
760
|
}
|
|
708
761
|
try {
|
|
@@ -715,6 +768,85 @@ function createProjectsRouter(getOpenspecDir, switchProject) {
|
|
|
715
768
|
return router;
|
|
716
769
|
}
|
|
717
770
|
|
|
771
|
+
// src/server/routes/terminal.ts
|
|
772
|
+
import { Router as Router7 } from "express";
|
|
773
|
+
import { exec } from "child_process";
|
|
774
|
+
import os from "os";
|
|
775
|
+
function createTerminalRouter() {
|
|
776
|
+
const router = Router7();
|
|
777
|
+
function getPlatform() {
|
|
778
|
+
const platform = os.platform();
|
|
779
|
+
if (platform === "win32") return "windows";
|
|
780
|
+
if (platform === "darwin") return "macos";
|
|
781
|
+
return "linux";
|
|
782
|
+
}
|
|
783
|
+
function tryTerminalCommand(command) {
|
|
784
|
+
return new Promise((resolve) => {
|
|
785
|
+
exec(command, (error, _stdout, _stderr) => {
|
|
786
|
+
if (error) {
|
|
787
|
+
resolve(null);
|
|
788
|
+
} else {
|
|
789
|
+
resolve(command);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
function buildTerminalCommand(projectPath, platform) {
|
|
795
|
+
const escapedPath = projectPath.replace(/"/g, '\\"');
|
|
796
|
+
switch (platform) {
|
|
797
|
+
case "windows":
|
|
798
|
+
return `start cmd /k "cd /d "${escapedPath}" && claude"`;
|
|
799
|
+
case "macos":
|
|
800
|
+
return `osascript -e 'tell app "Terminal"' -e 'activate' -e 'do script "cd \\"${escapedPath}\\" && claude"' -e 'end tell'`;
|
|
801
|
+
case "linux":
|
|
802
|
+
return `gnome-terminal -- bash -c 'cd "${escapedPath}" && claude; exec bash'`;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
router.post("/", async (req, res) => {
|
|
806
|
+
const { projectPath } = req.body;
|
|
807
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
808
|
+
return res.status(400).json({
|
|
809
|
+
success: false,
|
|
810
|
+
error: "\u9879\u76EE\u8DEF\u5F84\u65E0\u6548"
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
const trimmedPath = projectPath.trim();
|
|
814
|
+
if (!trimmedPath) {
|
|
815
|
+
return res.status(400).json({
|
|
816
|
+
success: false,
|
|
817
|
+
error: "\u9879\u76EE\u8DEF\u5F84\u4E3A\u7A7A"
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
const platform = getPlatform();
|
|
821
|
+
const command = buildTerminalCommand(trimmedPath, platform);
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
exec(command, (error, _stdout, stderr) => {
|
|
824
|
+
if (error) {
|
|
825
|
+
console.error("Failed to open terminal:", error);
|
|
826
|
+
console.error("stderr:", stderr);
|
|
827
|
+
return res.status(500).json({
|
|
828
|
+
success: false,
|
|
829
|
+
error: "\u65E0\u6CD5\u6253\u5F00\u7EC8\u7AEF",
|
|
830
|
+
details: process.env.NODE_ENV === "development" ? stderr : void 0
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
res.json({
|
|
834
|
+
success: true,
|
|
835
|
+
platform,
|
|
836
|
+
message: "\u7EC8\u7AEF\u5DF2\u6253\u5F00"
|
|
837
|
+
});
|
|
838
|
+
resolve();
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
router.post("/restart", (_req, res) => {
|
|
843
|
+
console.log("Restart requested, exiting process...");
|
|
844
|
+
res.json({ success: true, message: "\u9879\u76EE\u5DF2\u91CD\u542F" });
|
|
845
|
+
setTimeout(() => process.exit(1), 100);
|
|
846
|
+
});
|
|
847
|
+
return router;
|
|
848
|
+
}
|
|
849
|
+
|
|
718
850
|
// src/server/watcher.ts
|
|
719
851
|
import chokidar from "chokidar";
|
|
720
852
|
import fs10 from "fs";
|
|
@@ -894,6 +1026,11 @@ var SkillExecutor = class {
|
|
|
894
1026
|
currentStdin = null;
|
|
895
1027
|
currentSkill = "";
|
|
896
1028
|
currentChange = "";
|
|
1029
|
+
disableRealtimeProgress = process.env.DISABLE_REALTIME_PROGRESS === "true";
|
|
1030
|
+
// 跟踪有待处理问题且用户尚未响应
|
|
1031
|
+
waitingForUserResponse = false;
|
|
1032
|
+
// 跟踪是否有待处理问题被广播
|
|
1033
|
+
hasPendingQuestion = false;
|
|
897
1034
|
constructor(options) {
|
|
898
1035
|
this.openspecDir = options.openspecDir;
|
|
899
1036
|
this.projectDir = options.projectDir;
|
|
@@ -905,6 +1042,9 @@ var SkillExecutor = class {
|
|
|
905
1042
|
}
|
|
906
1043
|
this.currentSkill = skill;
|
|
907
1044
|
this.currentChange = change;
|
|
1045
|
+
this.waitingForUserResponse = false;
|
|
1046
|
+
this.hasPendingQuestion = false;
|
|
1047
|
+
const totalTasks = this.parseTotalTasks(skill, change);
|
|
908
1048
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
909
1049
|
const state = {
|
|
910
1050
|
skill,
|
|
@@ -912,7 +1052,7 @@ var SkillExecutor = class {
|
|
|
912
1052
|
status: "running",
|
|
913
1053
|
phase: skill === "apply" ? "implementing" : "creating_artifact",
|
|
914
1054
|
currentTask: 0,
|
|
915
|
-
totalTasks
|
|
1055
|
+
totalTasks,
|
|
916
1056
|
currentArtifact: null,
|
|
917
1057
|
startedAt: now,
|
|
918
1058
|
updatedAt: now
|
|
@@ -952,8 +1092,7 @@ var SkillExecutor = class {
|
|
|
952
1092
|
for (const line of lines) {
|
|
953
1093
|
if (!line.trim()) continue;
|
|
954
1094
|
try {
|
|
955
|
-
|
|
956
|
-
this.handleStreamEvent(event);
|
|
1095
|
+
this.handleStreamEvent(JSON.parse(line));
|
|
957
1096
|
} catch {
|
|
958
1097
|
this.appendLog(line);
|
|
959
1098
|
this.broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
@@ -969,6 +1108,21 @@ var SkillExecutor = class {
|
|
|
969
1108
|
this.currentProcess = null;
|
|
970
1109
|
this.currentStdin = null;
|
|
971
1110
|
const currentState = this.readState();
|
|
1111
|
+
if (this.hasPendingQuestion) {
|
|
1112
|
+
const completed = {
|
|
1113
|
+
...currentState,
|
|
1114
|
+
status: "completed",
|
|
1115
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1116
|
+
};
|
|
1117
|
+
this.writeState(completed);
|
|
1118
|
+
this.appendLog(`\u26A0 /opsx:${skill} \u8FDB\u7A0B\u5DF2\u9000\u51FA\uFF0C\u7B49\u5F85\u7528\u6237\u54CD\u5E94`);
|
|
1119
|
+
this.broadcast({ type: "skill_complete", data: completed });
|
|
1120
|
+
this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] \u26A0 /opsx:${skill} \u8FDB\u7A0B\u5DF2\u9000\u51FA\uFF0C\u8BF7\u9009\u62E9\u91CD\u65B0\u6267\u884C`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1121
|
+
this.waitingForUserResponse = false;
|
|
1122
|
+
this.hasPendingQuestion = false;
|
|
1123
|
+
resolve();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
972
1126
|
if (code === 0 && currentState.status === "running") {
|
|
973
1127
|
const completed = {
|
|
974
1128
|
...currentState,
|
|
@@ -991,6 +1145,8 @@ var SkillExecutor = class {
|
|
|
991
1145
|
this.broadcast({ type: "skill_complete", data: failed });
|
|
992
1146
|
this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] \u2717 /opsx:${skill} failed (exit code ${code})`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
993
1147
|
}
|
|
1148
|
+
this.waitingForUserResponse = false;
|
|
1149
|
+
this.hasPendingQuestion = false;
|
|
994
1150
|
resolve();
|
|
995
1151
|
});
|
|
996
1152
|
proc.on("error", (err) => {
|
|
@@ -1004,6 +1160,11 @@ var SkillExecutor = class {
|
|
|
1004
1160
|
this.writeState(failed);
|
|
1005
1161
|
this.appendLog(`\u2717 Failed to start: ${err.message}`);
|
|
1006
1162
|
this.broadcast({ type: "skill_complete", data: failed });
|
|
1163
|
+
if (this.hasPendingQuestion) {
|
|
1164
|
+
this.broadcast({ type: "skill_question", data: null });
|
|
1165
|
+
}
|
|
1166
|
+
this.waitingForUserResponse = false;
|
|
1167
|
+
this.hasPendingQuestion = false;
|
|
1007
1168
|
reject(err);
|
|
1008
1169
|
});
|
|
1009
1170
|
});
|
|
@@ -1022,6 +1183,11 @@ var SkillExecutor = class {
|
|
|
1022
1183
|
this.writeState(cancelled);
|
|
1023
1184
|
this.appendLog(`\u2717 /opsx:${this.currentSkill} cancelled by user`);
|
|
1024
1185
|
this.broadcast({ type: "skill_complete", data: cancelled });
|
|
1186
|
+
if (this.hasPendingQuestion) {
|
|
1187
|
+
this.broadcast({ type: "skill_question", data: null });
|
|
1188
|
+
}
|
|
1189
|
+
this.waitingForUserResponse = false;
|
|
1190
|
+
this.hasPendingQuestion = false;
|
|
1025
1191
|
return true;
|
|
1026
1192
|
}
|
|
1027
1193
|
isRunning() {
|
|
@@ -1035,6 +1201,9 @@ var SkillExecutor = class {
|
|
|
1035
1201
|
this.currentStdin.write(text + "\n");
|
|
1036
1202
|
this.appendLog(`[\u7528\u6237\u8F93\u5165] ${text}`);
|
|
1037
1203
|
this.broadcast({ type: "skill_log", data: { line: `[\u7528\u6237\u8F93\u5165] ${text}`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1204
|
+
this.waitingForUserResponse = false;
|
|
1205
|
+
this.hasPendingQuestion = false;
|
|
1206
|
+
this.broadcast({ type: "skill_question", data: null });
|
|
1038
1207
|
return { success: true };
|
|
1039
1208
|
} catch (err) {
|
|
1040
1209
|
return { success: false, error: "\u65E0\u6CD5\u53D1\u9001\u8F93\u5165" };
|
|
@@ -1054,6 +1223,43 @@ var SkillExecutor = class {
|
|
|
1054
1223
|
this.openspecDir = openspecDir;
|
|
1055
1224
|
this.projectDir = projectDir;
|
|
1056
1225
|
}
|
|
1226
|
+
isTaskCompletionLine(line) {
|
|
1227
|
+
const patterns = [
|
|
1228
|
+
/Task \d+ completed/i,
|
|
1229
|
+
/✓ Task/i,
|
|
1230
|
+
/^\s*✓\s*\d+\./,
|
|
1231
|
+
/Created task/i,
|
|
1232
|
+
/Working on task.*complete/i,
|
|
1233
|
+
/^✓\s.+\(task\s*\d+\/\d+\)/i
|
|
1234
|
+
];
|
|
1235
|
+
return patterns.some((p) => p.test(line));
|
|
1236
|
+
}
|
|
1237
|
+
parseTotalTasks(skill, change) {
|
|
1238
|
+
if (this.disableRealtimeProgress) return 0;
|
|
1239
|
+
try {
|
|
1240
|
+
if (skill === "apply") {
|
|
1241
|
+
const tasksPath = path10.join(this.openspecDir, "changes", change, "tasks.md");
|
|
1242
|
+
const content = fs11.readFileSync(tasksPath, "utf-8");
|
|
1243
|
+
const taskLines = content.split("\n").filter((line) => /^\s*-\s*\[[ x]\]\s*\d+\.?/.test(line));
|
|
1244
|
+
return taskLines.length;
|
|
1245
|
+
}
|
|
1246
|
+
} catch {
|
|
1247
|
+
}
|
|
1248
|
+
return 0;
|
|
1249
|
+
}
|
|
1250
|
+
updateTaskProgress() {
|
|
1251
|
+
if (this.disableRealtimeProgress) return;
|
|
1252
|
+
const state = this.readState();
|
|
1253
|
+
if (state.currentTask < state.totalTasks) {
|
|
1254
|
+
const updated = {
|
|
1255
|
+
...state,
|
|
1256
|
+
currentTask: state.currentTask + 1,
|
|
1257
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1258
|
+
};
|
|
1259
|
+
this.writeState(updated);
|
|
1260
|
+
this.broadcast({ type: "skill_progress", data: updated });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1057
1263
|
handleStreamEvent(event) {
|
|
1058
1264
|
const type = event.type;
|
|
1059
1265
|
if (type === "assistant") {
|
|
@@ -1066,13 +1272,40 @@ var SkillExecutor = class {
|
|
|
1066
1272
|
for (const line of lines) {
|
|
1067
1273
|
this.appendLog(line);
|
|
1068
1274
|
this.broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1275
|
+
if (this.isTaskCompletionLine(line)) {
|
|
1276
|
+
this.updateTaskProgress();
|
|
1277
|
+
}
|
|
1069
1278
|
}
|
|
1070
1279
|
}
|
|
1071
1280
|
if (block.type === "tool_use") {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1281
|
+
if (block.name === "AskUserQuestion") {
|
|
1282
|
+
const input = block.input;
|
|
1283
|
+
const questions = input?.questions;
|
|
1284
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
1285
|
+
const q = questions[0];
|
|
1286
|
+
const pendingQuestion = {
|
|
1287
|
+
question: q.question || "",
|
|
1288
|
+
header: q.header || "",
|
|
1289
|
+
options: Array.isArray(q.options) ? q.options.map((opt) => ({
|
|
1290
|
+
label: opt.label || "",
|
|
1291
|
+
description: opt.description || ""
|
|
1292
|
+
})) : [],
|
|
1293
|
+
multiSelect: q.multiSelect ?? false,
|
|
1294
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1295
|
+
};
|
|
1296
|
+
this.broadcast({ type: "skill_question", data: pendingQuestion });
|
|
1297
|
+
const friendlyLine = `\u23F3 \u7B49\u5F85\u9009\u62E9: ${pendingQuestion.question}`;
|
|
1298
|
+
this.appendLog(friendlyLine);
|
|
1299
|
+
this.broadcast({ type: "skill_log", data: { line: friendlyLine, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1300
|
+
this.hasPendingQuestion = true;
|
|
1301
|
+
this.waitingForUserResponse = true;
|
|
1302
|
+
}
|
|
1303
|
+
} else {
|
|
1304
|
+
const inputStr = block.input ? JSON.stringify(block.input) : "";
|
|
1305
|
+
const toolLine = `\u2192 ${block.name} ${inputStr}`;
|
|
1306
|
+
this.appendLog(toolLine);
|
|
1307
|
+
this.broadcast({ type: "skill_log", data: { line: toolLine, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
|
|
1308
|
+
}
|
|
1076
1309
|
}
|
|
1077
1310
|
}
|
|
1078
1311
|
}
|
|
@@ -1144,6 +1377,7 @@ async function createServer(options) {
|
|
|
1144
1377
|
app.use("/api/execution", createExecutionRouter(() => openspecDir));
|
|
1145
1378
|
app.use("/api/projects", createProjectsRouter(() => openspecDir, async () => {
|
|
1146
1379
|
}));
|
|
1380
|
+
app.use("/api/terminal", createTerminalRouter());
|
|
1147
1381
|
if (options.staticDir) {
|
|
1148
1382
|
app.use(express.static(options.staticDir));
|
|
1149
1383
|
app.get("*", (_req, res) => {
|
|
@@ -1184,6 +1418,7 @@ async function startServer(options) {
|
|
|
1184
1418
|
broadcast({ type: "refresh" });
|
|
1185
1419
|
};
|
|
1186
1420
|
app.use("/api/projects", createProjectsRouter(() => currentOpenspecDir, switchProject));
|
|
1421
|
+
app.use("/api/terminal", createTerminalRouter());
|
|
1187
1422
|
if (options.staticDir) {
|
|
1188
1423
|
app.use(express.static(options.staticDir));
|
|
1189
1424
|
app.get("*", (_req, res) => {
|