jsharness 1.3.0 → 1.4.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/bin/jsharness.js +22 -7
- package/lib/index.mjs +222 -482
- package/package.json +1 -1
package/bin/jsharness.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* jsharness CLI
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* npx jsharness init # 交互式初始化(选择工具+技术栈)
|
|
8
8
|
* npx jsharness init --tool codebuddy # 指定工具(跳过工具选择)
|
|
@@ -13,18 +13,34 @@
|
|
|
13
13
|
* npx jsharness openspec list # 列出 OpenSpec 变更
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
|
|
19
|
+
// 关键:用相对路径引用主库,不用 import('jsharness') 包名
|
|
20
|
+
// 这样在 npx 临时安装环境中也能可靠定位模块
|
|
21
|
+
// Windows 上必须用 file:// URL 格式,不能用 C:\path 格式
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const libPath = path.join(__dirname, '..', 'lib', 'index.mjs');
|
|
24
|
+
const libURL = pathToFileURL(libPath).href;
|
|
18
25
|
|
|
26
|
+
const { runInit, listTools, showStatus, listOpenSpecChanges, archiveOpenSpecChange } = await import(libURL);
|
|
27
|
+
|
|
28
|
+
// 读取版本号
|
|
29
|
+
import { createRequire } from 'module';
|
|
19
30
|
const require = createRequire(import.meta.url);
|
|
31
|
+
let version = '0.0.0';
|
|
32
|
+
try {
|
|
33
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
34
|
+
version = pkg.version;
|
|
35
|
+
} catch { /* ignore */ }
|
|
20
36
|
|
|
21
|
-
//
|
|
22
|
-
const {
|
|
37
|
+
// commander 延迟导入,确保路径解析先完成
|
|
38
|
+
const { program } = await import('commander');
|
|
23
39
|
|
|
24
40
|
program
|
|
25
41
|
.name('jsharness')
|
|
26
42
|
.description('Harness Engineering - AI 编程行为工程化管控系统')
|
|
27
|
-
.version(
|
|
43
|
+
.version(version);
|
|
28
44
|
|
|
29
45
|
program
|
|
30
46
|
.command('init')
|
|
@@ -47,7 +63,6 @@ program
|
|
|
47
63
|
.description('查看当前项目 Harness 初始化状态')
|
|
48
64
|
.action(() => showStatus(process.cwd()));
|
|
49
65
|
|
|
50
|
-
// OpenSpec 子命令
|
|
51
66
|
const openspecCmd = program
|
|
52
67
|
.command('openspec')
|
|
53
68
|
.description('OpenSpec 变更管理');
|
package/lib/index.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Harness Engineering - Core Library
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* 多适配器架构:检测 AI 工具 → 转换规则格式 → 注入到对应位置
|
|
5
|
+
*
|
|
6
|
+
* v1.4.0: 多源获取策略,确保 npx jsharness init 在任何项目中 100% 可用
|
|
7
|
+
* 1. import.meta.url 定位本包 .harness/ (最快)
|
|
8
|
+
* 2. process.argv[1] 反推包根目录(npx 直接执行时)
|
|
9
|
+
* 3. npm 全局安装目录搜索
|
|
10
|
+
* 4. npm registry 远端下载 tgz 解压
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
13
|
import fs from 'fs';
|
|
@@ -9,18 +15,14 @@ import path from 'path';
|
|
|
9
15
|
import { fileURLToPath } from 'url';
|
|
10
16
|
import readline from 'readline';
|
|
11
17
|
import https from 'https';
|
|
12
|
-
import zlib from 'zlib';
|
|
13
18
|
import { execSync } from 'child_process';
|
|
14
19
|
|
|
15
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
|
|
17
20
|
// ============================================================
|
|
18
|
-
//
|
|
21
|
+
// 常量
|
|
19
22
|
// ============================================================
|
|
20
23
|
|
|
21
|
-
const HARNESS_GITHUB_REPO = 'jieaiag/harness-engineering'; // TODO: 替换为实际仓库
|
|
22
24
|
const HARNESS_NPM_PACKAGE = 'jsharness';
|
|
23
|
-
const
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
25
27
|
// ============================================================
|
|
26
28
|
// 支持的 AI 工具清单
|
|
@@ -31,7 +33,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
31
33
|
id: 'codebuddy',
|
|
32
34
|
name: 'CodeBuddy (腾讯云代码助手)',
|
|
33
35
|
description: '.codebuddy/rules/ + .codebuddy/skills/',
|
|
34
|
-
detector: hasDir('.codebuddy') || hasFile('CODEBUDDY.md'),
|
|
35
36
|
priority: 100,
|
|
36
37
|
ruleFormat: 'markdown',
|
|
37
38
|
skillFormat: 'markdown',
|
|
@@ -40,16 +41,14 @@ export const SUPPORTED_TOOLS = [
|
|
|
40
41
|
id: 'cursor',
|
|
41
42
|
name: 'Cursor',
|
|
42
43
|
description: '.cursorrules 文件',
|
|
43
|
-
detector: hasFile('.cursorrules'),
|
|
44
44
|
priority: 90,
|
|
45
45
|
ruleFormat: 'cursor-md',
|
|
46
|
-
skillFormat: null,
|
|
46
|
+
skillFormat: null,
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
id: 'copilot',
|
|
50
50
|
name: 'GitHub Copilot',
|
|
51
51
|
description: '.github/copilot-instructions.md',
|
|
52
|
-
detector: hasDir('.github'),
|
|
53
52
|
priority: 80,
|
|
54
53
|
ruleFormat: 'copilot-md',
|
|
55
54
|
skillFormat: null,
|
|
@@ -58,7 +57,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
58
57
|
id: 'windsurf',
|
|
59
58
|
name: 'Windsurf (Codeium)',
|
|
60
59
|
description: '.windsurfrules 目录',
|
|
61
|
-
detector: hasFile('.windsurfrules') || hasDir('.windsurf'),
|
|
62
60
|
priority: 70,
|
|
63
61
|
ruleFormat: 'windsurf-md',
|
|
64
62
|
skillFormat: null,
|
|
@@ -67,7 +65,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
67
65
|
id: 'continue',
|
|
68
66
|
name: 'Continue (VS Code)',
|
|
69
67
|
description: 'continue.config.json',
|
|
70
|
-
detector: hasFile('continue.config.json'),
|
|
71
68
|
priority: 60,
|
|
72
69
|
ruleFormat: 'continue-json',
|
|
73
70
|
skillFormat: null,
|
|
@@ -76,7 +73,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
76
73
|
id: 'cline',
|
|
77
74
|
name: 'Cline (VS Code)',
|
|
78
75
|
description: '.clinerules 文件',
|
|
79
|
-
detector: hasFile('.clinerules'),
|
|
80
76
|
priority: 50,
|
|
81
77
|
ruleFormat: 'cline-md',
|
|
82
78
|
skillFormat: null,
|
|
@@ -85,7 +81,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
85
81
|
id: 'qoder',
|
|
86
82
|
name: 'Qoder (阿里云)',
|
|
87
83
|
description: '.qoder/rules/ 目录',
|
|
88
|
-
detector: hasDir('.qoder'),
|
|
89
84
|
priority: 95,
|
|
90
85
|
ruleFormat: 'markdown',
|
|
91
86
|
skillFormat: 'markdown',
|
|
@@ -94,7 +89,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
94
89
|
id: 'codex',
|
|
95
90
|
name: 'Codex CLI (OpenAI)',
|
|
96
91
|
description: 'AGENTS.md 文件(项目根目录)',
|
|
97
|
-
detector: hasFile('AGENTS.md') || hasDir('.codex'),
|
|
98
92
|
priority: 85,
|
|
99
93
|
ruleFormat: 'agents-md',
|
|
100
94
|
skillFormat: null,
|
|
@@ -103,7 +97,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
103
97
|
id: 'claude-code',
|
|
104
98
|
name: 'Claude Code (Anthropic)',
|
|
105
99
|
description: '.claude/rules/ + CLAUDE.md',
|
|
106
|
-
detector: hasFile('CLAUDE.md') || hasDir('.claude'),
|
|
107
100
|
priority: 93,
|
|
108
101
|
ruleFormat: 'claude-rules-md',
|
|
109
102
|
skillFormat: 'claude-skills-md',
|
|
@@ -112,7 +105,6 @@ export const SUPPORTED_TOOLS = [
|
|
|
112
105
|
id: 'trae',
|
|
113
106
|
name: 'Trae (字节跳动)',
|
|
114
107
|
description: '.trae/rules/project_rules.md',
|
|
115
|
-
detector: hasDir('.trae') || hasFile('.trae/rules/project_rules.md'),
|
|
116
108
|
priority: 75,
|
|
117
109
|
ruleFormat: 'trae-rules-md',
|
|
118
110
|
skillFormat: null,
|
|
@@ -123,36 +115,35 @@ export const SUPPORTED_TOOLS = [
|
|
|
123
115
|
// 检测工具函数
|
|
124
116
|
// ============================================================
|
|
125
117
|
|
|
126
|
-
function hasDir(name) {
|
|
127
|
-
return () => fs.existsSync(name) && fs.lstatSync(name).isDirectory();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function hasFile(name) {
|
|
131
|
-
return () => fs.existsSync(name) && fs.lstatSync(name).isFile();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
118
|
/**
|
|
135
119
|
* 自动检测当前项目使用的 AI 工具
|
|
136
120
|
*/
|
|
137
121
|
export function detectTool(projectDir) {
|
|
122
|
+
const detectors = {
|
|
123
|
+
codebuddy: ['.codebuddy', 'CODEBUDDY.md'],
|
|
124
|
+
cursor: ['.cursorrules'],
|
|
125
|
+
copilot: ['.github'],
|
|
126
|
+
windsurf: ['.windsurfrules', '.windsurf'],
|
|
127
|
+
continue: ['continue.config.json'],
|
|
128
|
+
cline: ['.clinerules'],
|
|
129
|
+
qoder: ['.qoder'],
|
|
130
|
+
codex: ['AGENTS.md', '.codex'],
|
|
131
|
+
'claude-code': ['CLAUDE.md', '.claude'],
|
|
132
|
+
trae: ['.trae'],
|
|
133
|
+
};
|
|
134
|
+
|
|
138
135
|
const results = [];
|
|
139
|
-
|
|
140
136
|
for (const tool of SUPPORTED_TOOLS) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
: fs.existsSync(path.join(projectDir, typeof detectFn === 'string' ? detectFn : ''));
|
|
146
|
-
|
|
147
|
-
if (isDetected) {
|
|
137
|
+
const markers = detectors[tool.id] || [];
|
|
138
|
+
for (const marker of markers) {
|
|
139
|
+
const fullPath = path.join(projectDir, marker);
|
|
140
|
+
if (fs.existsSync(fullPath)) {
|
|
148
141
|
results.push({ ...tool, detected: true });
|
|
142
|
+
break;
|
|
149
143
|
}
|
|
150
|
-
} catch {
|
|
151
|
-
// 检测失败,跳过
|
|
152
144
|
}
|
|
153
145
|
}
|
|
154
146
|
|
|
155
|
-
// 按优先级排序,返回最匹配的
|
|
156
147
|
results.sort((a, b) => b.priority - a.priority);
|
|
157
148
|
return results;
|
|
158
149
|
}
|
|
@@ -161,17 +152,11 @@ export function detectTool(projectDir) {
|
|
|
161
152
|
// 规则转换引擎
|
|
162
153
|
// ============================================================
|
|
163
154
|
|
|
164
|
-
/**
|
|
165
|
-
* 将 .harness/rules/*.md 转换为目标工具的格式
|
|
166
|
-
*/
|
|
167
155
|
export function transformRules(ruleFiles, targetToolId, options = {}) {
|
|
168
156
|
const transformer = RULE_TRANSFORMERS[targetToolId] || RULE_TRANSFORMERS['generic'];
|
|
169
157
|
return transformer(ruleFiles, options);
|
|
170
158
|
}
|
|
171
159
|
|
|
172
|
-
/**
|
|
173
|
-
* 将 .harness/skills/*.md 转换为目标工具的格式
|
|
174
|
-
*/
|
|
175
160
|
export function transformSkills(skillFiles, targetToolId, options = {}) {
|
|
176
161
|
const transformer = SKILL_TRANSFORMERS[targetToolId] || SKILL_TRANSFORMERS['generic'];
|
|
177
162
|
return transformer(skillFiles, options);
|
|
@@ -183,169 +168,90 @@ export function transformSkills(skillFiles, targetToolId, options = {}) {
|
|
|
183
168
|
|
|
184
169
|
const RULE_TRANSFORMERS = {
|
|
185
170
|
|
|
186
|
-
/** CodeBuddy: 原样复制 .md 到 .codebuddy/rules/ */
|
|
187
171
|
codebuddy(ruleFiles, opts) {
|
|
188
172
|
const outputs = [];
|
|
189
173
|
for (const file of ruleFiles) {
|
|
190
174
|
const content = fs.readFileSync(file.path, 'utf-8');
|
|
191
|
-
outputs.push({
|
|
192
|
-
relativePath: `.codebuddy/rules/${file.name}`,
|
|
193
|
-
content,
|
|
194
|
-
source: file.path,
|
|
195
|
-
});
|
|
175
|
+
outputs.push({ relativePath: `.codebuddy/rules/${file.name}`, content, source: file.path });
|
|
196
176
|
}
|
|
197
177
|
return { format: 'directory', files: outputs };
|
|
198
178
|
},
|
|
199
179
|
|
|
200
|
-
/** Cursor: 合并所有规则为 .cursorrules */
|
|
201
180
|
cursor(ruleFiles, opts) {
|
|
202
|
-
let content = `# Harness Engineering Rules\n`;
|
|
203
|
-
content += `> Generated by hariness on ${new Date().toISOString().split('T')[0]}\n\n`;
|
|
204
|
-
|
|
181
|
+
let content = `# Harness Engineering Rules\n> Generated by hariness on ${new Date().toISOString().split('T')[0]}\n\n`;
|
|
205
182
|
for (const file of ruleFiles) {
|
|
206
|
-
|
|
207
|
-
content += extractBody(raw);
|
|
183
|
+
content += extractBody(fs.readFileSync(file.path, 'utf-8'));
|
|
208
184
|
content += '\n---\n\n';
|
|
209
185
|
}
|
|
210
|
-
|
|
211
186
|
return { format: 'single-file', files: [{ relativePath: '.cursorrules', content }] };
|
|
212
187
|
},
|
|
213
188
|
|
|
214
|
-
/** GitHub Copilot: 合并为 .github/copilot-instructions.md */
|
|
215
189
|
copilot(ruleFiles, opts) {
|
|
216
|
-
let content = `# Project Rules & Guidelines\n\n`;
|
|
217
|
-
content += `## Harness Engineering System\n\n`;
|
|
218
|
-
|
|
190
|
+
let content = `# Project Rules & Guidelines\n\n## Harness Engineering System\n\n`;
|
|
219
191
|
for (const file of ruleFiles) {
|
|
220
192
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
221
|
-
content += `### ${extractTitle(raw)}\n\n`;
|
|
222
|
-
content += extractBody(raw);
|
|
223
|
-
content += '\n\n';
|
|
193
|
+
content += `### ${extractTitle(raw)}\n\n${extractBody(raw)}\n\n`;
|
|
224
194
|
}
|
|
225
|
-
|
|
226
195
|
return { format: 'single-file', files: [{ relativePath: '.github/copilot-instructions.md', content }] };
|
|
227
196
|
},
|
|
228
197
|
|
|
229
|
-
/** Windsurf: .windsurfrules 格式 */
|
|
230
198
|
windsurf(ruleFiles, opts) {
|
|
231
199
|
let content = '';
|
|
232
200
|
for (const file of ruleFiles) {
|
|
233
201
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
234
|
-
content += `<rules>\n`;
|
|
235
|
-
content += `<rule name="${file.name.replace('.md', '')}">\n`;
|
|
236
|
-
content += extractBody(raw).trim();
|
|
237
|
-
content += `\n</rule>\n</rules>\n\n`;
|
|
202
|
+
content += `<rules>\n<rule name="${file.name.replace('.md', '')}">\n${extractBody(raw).trim()}\n</rule>\n</rules>\n\n`;
|
|
238
203
|
}
|
|
239
204
|
return { format: 'single-file', files: [{ relativePath: '.windsurfrules', content }] };
|
|
240
205
|
},
|
|
241
206
|
|
|
242
|
-
/** Continue: continue.config.json 格式 */
|
|
243
207
|
continue(ruleFiles, opts) {
|
|
244
|
-
const rules =
|
|
245
|
-
for (const file of ruleFiles) {
|
|
208
|
+
const rules = ruleFiles.map(file => {
|
|
246
209
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
description: extractDescription(raw),
|
|
251
|
-
content: extractBody(raw),
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
const config = { rules };
|
|
255
|
-
const content = JSON.stringify(config, null, 2);
|
|
256
|
-
return { format: 'json-config', files: [{ relativePath: 'continue.config.json', content }] };
|
|
210
|
+
return { name: file.name.replace('.md', ''), type: 'rule', description: extractDescription(raw), content: extractBody(raw) };
|
|
211
|
+
});
|
|
212
|
+
return { format: 'json-config', files: [{ relativePath: 'continue.config.json', content: JSON.stringify({ rules }, null, 2) }] };
|
|
257
213
|
},
|
|
258
214
|
|
|
259
|
-
/** Cline: .clinerules 格式(类似 Cursor) */
|
|
260
215
|
cline(ruleFiles, opts) {
|
|
261
216
|
let content = `# Harness Engineering Rules for Cline\n\n`;
|
|
262
217
|
for (const file of ruleFiles) {
|
|
263
218
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
264
|
-
content += `## ${extractTitle(raw)}\n\n`;
|
|
265
|
-
content += extractBody(raw);
|
|
266
|
-
content += '\n---\n\n';
|
|
219
|
+
content += `## ${extractTitle(raw)}\n\n${extractBody(raw)}\n---\n\n`;
|
|
267
220
|
}
|
|
268
221
|
return { format: 'single-file', files: [{ relativePath: '.clinerules', content }] };
|
|
269
222
|
},
|
|
270
223
|
|
|
271
|
-
/**
|
|
272
|
-
* ================================================================
|
|
273
|
-
* 新增工具适配器 (v1.1+)
|
|
274
|
-
* ================================================================
|
|
275
|
-
*/
|
|
276
|
-
|
|
277
|
-
/** Qoder: 原样复制 .md 到 .qoder/rules/ (与 CodeBuddy 相同模式) */
|
|
278
224
|
qoder(ruleFiles, opts) {
|
|
279
225
|
const outputs = [];
|
|
280
226
|
for (const file of ruleFiles) {
|
|
281
227
|
const content = fs.readFileSync(file.path, 'utf-8');
|
|
282
|
-
outputs.push({
|
|
283
|
-
relativePath: `.qoder/rules/${file.name}`,
|
|
284
|
-
content,
|
|
285
|
-
source: file.path,
|
|
286
|
-
});
|
|
228
|
+
outputs.push({ relativePath: `.qoder/rules/${file.name}`, content, source: file.path });
|
|
287
229
|
}
|
|
288
230
|
return { format: 'directory', files: outputs };
|
|
289
231
|
},
|
|
290
232
|
|
|
291
|
-
/** Codex CLI: 合并所有规则为 AGENTS.md(项目根目录) */
|
|
292
233
|
codex(ruleFiles, opts) {
|
|
293
|
-
let content = `# Harness Engineering - Project Instructions for Codex CLI\n`;
|
|
294
|
-
content +=
|
|
295
|
-
content += `> Source: .harness/ rules & skills system\n\n`;
|
|
296
|
-
|
|
297
|
-
// Codex AGENTS.md 推荐结构:Commands / Architecture / Conventions / Important
|
|
298
|
-
content += `## Overview\n\n`;
|
|
299
|
-
content += `This project uses **Harness Engineering** system with the following tech stack:\n\n`;
|
|
300
|
-
content += `- Frontend: Vue3 + Vite + TypeScript + Pinia + Element Plus\n`;
|
|
301
|
-
content += `- Backend: Spring Boot 3.2 + JDK21 + MyBatis-Plus + Nacos\n\n`;
|
|
302
|
-
|
|
303
|
-
content += `---\n\n## Conventions & Rules\n\n`;
|
|
304
|
-
|
|
234
|
+
let content = `# Harness Engineering - Project Instructions for Codex CLI\n> Generated by hariness on ${new Date().toISOString().split('T')[0]}\n> Source: .harness/ rules & skills system\n\n`;
|
|
235
|
+
content += `## Overview\n\nThis project uses **Harness Engineering** system with the following tech stack:\n\n- Frontend: Vue3 + Vite + TypeScript + Pinia + Element Plus\n- Backend: Spring Boot 3.2 + JDK21 + MyBatis-Plus + Nacos\n\n---\n\n## Conventions & Rules\n\n`;
|
|
305
236
|
for (const file of ruleFiles) {
|
|
306
237
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
307
|
-
content += `### ${extractTitle(raw)}\n\n`;
|
|
308
|
-
content += extractBody(raw);
|
|
309
|
-
content += '\n\n';
|
|
238
|
+
content += `### ${extractTitle(raw)}\n\n${extractBody(raw)}\n\n`;
|
|
310
239
|
}
|
|
311
|
-
|
|
312
|
-
// 追加 Skills 摘要
|
|
313
|
-
content += `---\n\n## Available Build/Test Skills\n\n`;
|
|
314
|
-
content += 'See .harness/skills/ directory for detailed build, lint, test, and review procedures.\n';
|
|
315
|
-
content += `- \`npm run build\` — Vue3 frontend production build\n`;
|
|
316
|
-
content += `- \`mvn compile test\` — Java backend build with tests\n`;
|
|
317
|
-
content += `- \`node .harness/gate/index.js\` — Run gate compliance checks\n\n`;
|
|
318
|
-
|
|
240
|
+
content += `---\n\n## Available Build/Test Skills\n\nSee .harness/skills/ directory for detailed build, lint, test, and review procedures.\n- \`npm run build\` — Vue3 frontend production build\n- \`mvn compile test\` — Java backend build with tests\n- \`node .harness/gate/index.js\` — Run gate compliance checks\n\n`;
|
|
319
241
|
return { format: 'single-file', files: [{ relativePath: 'AGENTS.md', content }] };
|
|
320
242
|
},
|
|
321
243
|
|
|
322
|
-
/** Claude Code: 输出为 .claude/rules/*.md(支持 paths: frontmatter 路径限定) */
|
|
323
244
|
'claude-code'(ruleFiles, opts) {
|
|
324
245
|
const outputs = [];
|
|
325
246
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
claudeMd +=
|
|
329
|
-
claudeMd += `## Tech Stack\n`;
|
|
330
|
-
claudeMd += `- **Frontend**: Vue3 + Vite + TypeScript + Pinia + Element Plus\n`;
|
|
331
|
-
claudeMd += `- **Backend**: Spring Boot 3.2 (JDK21 Virtual Threads) + MyBatis-Plus\n\n`;
|
|
332
|
-
claudeMd += `## Commands\n`;
|
|
333
|
-
claudeMd += `\`\`\`bash\n`;
|
|
334
|
-
claudeMd += `npm run build # Frontend build\n`;
|
|
335
|
-
claudeMd += `npm run lint # ESLint check\n`;
|
|
336
|
-
claudeMd += `mvn compile test # Backend build + test\n`;
|
|
337
|
-
claudeMd += `node .harness/gate/index.js # Gate checks\n`;
|
|
338
|
-
claudeMd += `\`\`\`\n\n`;
|
|
339
|
-
claudeMd += `> Detailed rules are loaded from \`.claude/rules/\` files below.\n`;
|
|
340
|
-
|
|
247
|
+
let claudeMd = `# Harness Engineering System\n\n> Auto-generated by hariness. Full rules in \`.claude/rules/\`\n\n`;
|
|
248
|
+
claudeMd += `## Tech Stack\n- **Frontend**: Vue3 + Vite + TypeScript + Pinia + Element Plus\n- **Backend**: Spring Boot 3.2 (JDK21 Virtual Threads) + MyBatis-Plus\n\n`;
|
|
249
|
+
claudeMd += `## Commands\n\`\`\`bash\nnpm run build # Frontend build\nnpm run lint # ESLint check\nmvn compile test # Backend build + test\nnode .harness/gate/index.js # Gate checks\n\`\`\`\n\n> Detailed rules are loaded from \`.claude/rules/\` files below.\n`;
|
|
341
250
|
outputs.push({ relativePath: 'CLAUDE.md', content: claudeMd });
|
|
342
251
|
|
|
343
|
-
// 为每个规则文件生成独立的 .claude/rules/*.md,带路径限定 frontmatter
|
|
344
252
|
for (const file of ruleFiles) {
|
|
345
253
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
346
254
|
const body = extractBody(raw);
|
|
347
|
-
|
|
348
|
-
// 根据规则类别决定 paths 范围
|
|
349
255
|
let scopePaths = null;
|
|
350
256
|
if (file.category === 'project') {
|
|
351
257
|
if (file.relativePath.includes('vue3') || file.relativePath.includes('frontend') || file.relativePath.includes('web')) {
|
|
@@ -354,48 +260,28 @@ const RULE_TRANSFORMERS = {
|
|
|
354
260
|
scopePaths = ['src/main/**/*.java', '**/*.java'];
|
|
355
261
|
}
|
|
356
262
|
}
|
|
357
|
-
|
|
358
263
|
let content = '';
|
|
359
264
|
if (scopePaths) {
|
|
360
265
|
content += `---\npaths:\n`;
|
|
361
|
-
for (const p of scopePaths) {
|
|
362
|
-
content += ` - "${p}"\n`;
|
|
363
|
-
}
|
|
266
|
+
for (const p of scopePaths) content += ` - "${p}"\n`;
|
|
364
267
|
content += `---\n\n`;
|
|
365
268
|
}
|
|
366
|
-
|
|
367
269
|
content += body;
|
|
368
|
-
|
|
369
|
-
outputs.push({
|
|
370
|
-
relativePath: `.claude/rules/${file.name}`,
|
|
371
|
-
content,
|
|
372
|
-
source: file.path,
|
|
373
|
-
});
|
|
270
|
+
outputs.push({ relativePath: `.claude/rules/${file.name}`, content, source: file.path });
|
|
374
271
|
}
|
|
375
272
|
|
|
376
273
|
return { format: 'multi-file', files: outputs };
|
|
377
274
|
},
|
|
378
275
|
|
|
379
|
-
/** Trae: 合并为 .trae/rules/project_rules.md */
|
|
380
276
|
trae(ruleFiles, opts) {
|
|
381
|
-
let content = `# Harness Engineering Rules for Trae\n\n`;
|
|
382
|
-
content += `> Generated by hariness on ${new Date().toISOString().split('T')[0]}\n\n`;
|
|
383
|
-
content += `## 技术栈\n\n`;
|
|
384
|
-
content += `- 前端: Vue3 + Vite + TypeScript + Pinia + Element Plus\n`;
|
|
385
|
-
content += `- 后端: Spring Boot 3.2 + JDK21 + MyBatis-Plus\n\n`;
|
|
386
|
-
content += `---\n\n`;
|
|
387
|
-
|
|
277
|
+
let content = `# Harness Engineering Rules for Trae\n\n> Generated by hariness on ${new Date().toISOString().split('T')[0]}\n\n## 技术栈\n\n- 前端: Vue3 + Vite + TypeScript + Pinia + Element Plus\n- 后端: Spring Boot 3.2 + JDK21 + MyBatis-Plus\n\n---\n\n`;
|
|
388
278
|
for (const file of ruleFiles) {
|
|
389
279
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
390
|
-
content += `### ${extractTitle(raw)}\n\n`;
|
|
391
|
-
content += extractBody(raw);
|
|
392
|
-
content += '\n\n---\n\n';
|
|
280
|
+
content += `### ${extractTitle(raw)}\n\n${extractBody(raw)}\n\n---\n\n`;
|
|
393
281
|
}
|
|
394
|
-
|
|
395
282
|
return { format: 'single-file', files: [{ relativePath: '.trae/rules/project_rules.md', content }] };
|
|
396
283
|
},
|
|
397
284
|
|
|
398
|
-
/** Generic fallback */
|
|
399
285
|
generic(ruleFiles, opts) {
|
|
400
286
|
return RULE_TRANSFORMERS.codebuddy(ruleFiles, opts);
|
|
401
287
|
},
|
|
@@ -403,74 +289,43 @@ const RULE_TRANSFORMERS = {
|
|
|
403
289
|
|
|
404
290
|
const SKILL_TRANSFORMERS = {
|
|
405
291
|
|
|
406
|
-
/** CodeBuddy: 复制到 .codebuddy/skills/ */
|
|
407
292
|
codebuddy(skillFiles, opts) {
|
|
408
293
|
const outputs = [];
|
|
409
294
|
for (const file of skillFiles) {
|
|
410
|
-
|
|
411
|
-
outputs.push({
|
|
412
|
-
relativePath: `.codebuddy/skills/${file.name}`,
|
|
413
|
-
content,
|
|
414
|
-
source: file.path,
|
|
415
|
-
});
|
|
295
|
+
outputs.push({ relativePath: `.codebuddy/skills/${file.name}`, content: fs.readFileSync(file.path, 'utf-8'), source: file.path });
|
|
416
296
|
}
|
|
417
297
|
return { format: 'directory', files: outputs };
|
|
418
298
|
},
|
|
419
299
|
|
|
420
|
-
/** 不支持 skills 的工具 → 合并到规则文件中 */
|
|
421
300
|
generic(skillFiles, opts) {
|
|
422
301
|
const outputs = [];
|
|
423
302
|
for (const file of skillFiles) {
|
|
424
|
-
|
|
425
|
-
outputs.push({
|
|
426
|
-
relativePath: `.harness/skills/${file.name}`, // 保持原始位置作为参考
|
|
427
|
-
content,
|
|
428
|
-
source: file.path,
|
|
429
|
-
});
|
|
303
|
+
outputs.push({ relativePath: `.harness/skills/${file.name}`, content: fs.readFileSync(file.path, 'utf-8'), source: file.path });
|
|
430
304
|
}
|
|
431
305
|
return { format: 'reference', files: outputs };
|
|
432
306
|
},
|
|
433
307
|
|
|
434
|
-
/** Qoder: 复制到 .qoder/skills/ */
|
|
435
308
|
qoder(skillFiles, opts) {
|
|
436
309
|
const outputs = [];
|
|
437
310
|
for (const file of skillFiles) {
|
|
438
|
-
|
|
439
|
-
outputs.push({
|
|
440
|
-
relativePath: `.qoder/skills/${file.name}`,
|
|
441
|
-
content,
|
|
442
|
-
source: file.path,
|
|
443
|
-
});
|
|
311
|
+
outputs.push({ relativePath: `.qoder/skills/${file.name}`, content: fs.readFileSync(file.path, 'utf-8'), source: file.path });
|
|
444
312
|
}
|
|
445
313
|
return { format: 'directory', files: outputs };
|
|
446
314
|
},
|
|
447
315
|
|
|
448
|
-
/** Claude Code: 复制到 .claude/skills/ (Claude 支持自动触发的 skills) */
|
|
449
316
|
'claude-code'(skillFiles, opts) {
|
|
450
317
|
const outputs = [];
|
|
451
318
|
for (const file of skillFiles) {
|
|
452
319
|
const raw = fs.readFileSync(file.path, 'utf-8');
|
|
453
320
|
const body = extractBody(raw);
|
|
454
|
-
|
|
455
|
-
// Claude Code skills 格式:YAML frontmatter + Markdown body
|
|
456
|
-
let content = `---\n`;
|
|
457
|
-
content += `name: ${file.name.replace('.md', '')}\n`;
|
|
458
|
-
content += `description: Harness Engineering Skill - ${extractDescription(raw) || file.name}\n`;
|
|
459
|
-
// 自动触发条件
|
|
321
|
+
let content = `---\nname: ${file.name.replace('.md', '')}\ndescription: Harness Engineering Skill - ${extractDescription(raw) || file.name}\n`;
|
|
460
322
|
const lowerName = file.name.toLowerCase();
|
|
461
323
|
if (lowerName.includes('build')) content += `trigger_type: manual\n`;
|
|
462
324
|
else if (lowerName.includes('test')) content += `trigger_type: on_save\n`;
|
|
463
325
|
else if (lowerName.includes('lint') || lowerName.includes('review')) content += `trigger_type: on_pr\n`;
|
|
464
326
|
else content += `trigger_type: manual\n`;
|
|
465
|
-
content += `---\n\n`;
|
|
466
|
-
|
|
467
|
-
content += body;
|
|
468
|
-
|
|
469
|
-
outputs.push({
|
|
470
|
-
relativePath: `.claude/skills/${file.name}`,
|
|
471
|
-
content,
|
|
472
|
-
source: file.path,
|
|
473
|
-
});
|
|
327
|
+
content += `---\n\n${body}`;
|
|
328
|
+
outputs.push({ relativePath: `.claude/skills/${file.name}`, content, source: file.path });
|
|
474
329
|
}
|
|
475
330
|
return { format: 'directory', files: outputs };
|
|
476
331
|
},
|
|
@@ -486,129 +341,119 @@ function extractTitle(markdown) {
|
|
|
486
341
|
}
|
|
487
342
|
|
|
488
343
|
function extractDescription(markdown) {
|
|
489
|
-
// 尝试提取第一段描述性文字
|
|
490
344
|
const body = markdown.replace(/^#.+/gm, '').trim();
|
|
491
345
|
const firstPara = body.match(/^(.+)/m);
|
|
492
346
|
return firstPara ? firstPara[1].slice(0, 120) : '';
|
|
493
347
|
}
|
|
494
348
|
|
|
495
349
|
function extractBody(markdown) {
|
|
496
|
-
// 移除 frontmatter (--- ... ---)
|
|
497
350
|
let body = markdown;
|
|
498
351
|
if (body.startsWith('---')) {
|
|
499
352
|
const endIdx = body.indexOf('---', 3);
|
|
500
|
-
if (endIdx > 0)
|
|
501
|
-
body = body.slice(endIdx + 3).trim();
|
|
502
|
-
}
|
|
353
|
+
if (endIdx > 0) body = body.slice(endIdx + 3).trim();
|
|
503
354
|
}
|
|
504
355
|
return body;
|
|
505
356
|
}
|
|
506
357
|
|
|
507
358
|
// ============================================================
|
|
508
|
-
//
|
|
359
|
+
// 多源获取 .harness/ 模板(核心:确保任何项目都能命中)
|
|
509
360
|
// ============================================================
|
|
510
361
|
|
|
511
362
|
/**
|
|
512
363
|
* 获取 .harness/ 模板源目录
|
|
513
364
|
*
|
|
514
|
-
*
|
|
515
|
-
* 1.
|
|
516
|
-
* 2.
|
|
517
|
-
* 3.
|
|
518
|
-
*
|
|
519
|
-
* 类似 OpenSpec/SpecKit 的初始化方式:不依赖项目本地 node_modules,
|
|
520
|
-
* 而是从远端(npm registry)直接拉取模板文件写入目标项目。
|
|
521
|
-
*
|
|
522
|
-
* @param {object} [options]
|
|
523
|
-
* @param {boolean} [options.verbose]
|
|
524
|
-
* @returns {Promise<string|null>} .harness/ 目录的绝对路径,全部失败返回 null
|
|
365
|
+
* 四级回退策略,确保 npx jsharness init 在任何环境下 100% 可用:
|
|
366
|
+
* 1. import.meta.url 定位本包 .harness/ — npx 缓存正常时最快
|
|
367
|
+
* 2. process.argv[1] / npm 全局安装目录反推 — 全局安装时
|
|
368
|
+
* 3. require.resolve 模块解析 — npm install 后
|
|
369
|
+
* 4. npm registry 远端下载 tgz 解压 — 纯净环境兜底
|
|
525
370
|
*/
|
|
526
371
|
async function getHarnessSourceDir(options = {}) {
|
|
527
372
|
const { verbose = false } = options;
|
|
373
|
+
const tried = [];
|
|
528
374
|
|
|
529
|
-
// ── 策略 1:
|
|
530
|
-
const libDir =
|
|
375
|
+
// ── 策略 1: import.meta.url 定位(npx 缓存中 lib/ 旁边就有 .harness/)──
|
|
376
|
+
const libDir = __dirname;
|
|
531
377
|
const bundledDir = path.join(libDir, '..', '.harness');
|
|
378
|
+
tried.push(`import.meta.url → ${bundledDir}`);
|
|
532
379
|
if (fs.existsSync(bundledDir) && fs.existsSync(path.join(bundledDir, 'rules'))) {
|
|
533
380
|
if (verbose) console.log(` 📂 源=本包自带: ${bundledDir}`);
|
|
534
381
|
return bundledDir;
|
|
535
382
|
}
|
|
536
383
|
|
|
537
|
-
// ── 策略 2:
|
|
384
|
+
// ── 策略 2: 从 CLI 入口文件反推包根目录 ──
|
|
385
|
+
// process.argv[1] = /path/to/npx-cache/.../node_modules/jsharness/bin/jsharness.js
|
|
386
|
+
// 包根 = bin/ 的上一级
|
|
538
387
|
try {
|
|
539
|
-
const
|
|
540
|
-
if (
|
|
541
|
-
|
|
542
|
-
|
|
388
|
+
const cliPath = process.argv[1];
|
|
389
|
+
if (cliPath) {
|
|
390
|
+
const cliDir = path.dirname(path.resolve(cliPath));
|
|
391
|
+
// 可能是 bin/ 目录 → 上级就是包根
|
|
392
|
+
const parentDir = path.dirname(cliDir);
|
|
393
|
+
const hDir = path.join(parentDir, '.harness');
|
|
394
|
+
tried.push(`argv[1] → ${hDir}`);
|
|
395
|
+
if (fs.existsSync(hDir) && fs.existsSync(path.join(hDir, 'rules'))) {
|
|
396
|
+
if (verbose) console.log(` 📂 源=CLI入口反推: ${hDir}`);
|
|
397
|
+
return hDir;
|
|
398
|
+
}
|
|
399
|
+
// npx 有时结构是 node_modules/jsharness/bin/ → 向上两级
|
|
400
|
+
if (path.basename(cliDir) === 'bin') {
|
|
401
|
+
const hDir2 = path.join(path.dirname(parentDir), '.harness');
|
|
402
|
+
tried.push(`argv[1]+1 → ${hDir2}`);
|
|
403
|
+
if (fs.existsSync(hDir2) && fs.existsSync(path.join(hDir2, 'rules'))) {
|
|
404
|
+
if (verbose) console.log(` 📂 源=CLI入口反推(深): ${hDir2}`);
|
|
405
|
+
return hDir2;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
543
408
|
}
|
|
544
409
|
} catch { /* ignore */ }
|
|
545
410
|
|
|
546
|
-
// ── 策略 3:
|
|
547
|
-
console.log(' ⬇️ 本地未找到 .harness/ 模板,从 npm 远端拉取...');
|
|
548
|
-
const remoteDir = await downloadAndExtractFromNpm(options);
|
|
549
|
-
if (remoteDir) {
|
|
550
|
-
if (verbose) console.log(` 📂 源=npm远端: ${remoteDir}`);
|
|
551
|
-
return remoteDir;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
return null;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* 在 npx 缓存目录中搜索包含 .harness/ 的 jsharness 包
|
|
559
|
-
*/
|
|
560
|
-
function findNpxCacheDir() {
|
|
561
|
-
const candidates = [];
|
|
562
|
-
|
|
563
|
-
// npm 缓存根目录
|
|
411
|
+
// ── 策略 3: npm 全局安装路径搜索 ──
|
|
564
412
|
try {
|
|
565
|
-
const
|
|
566
|
-
|
|
413
|
+
const globalDir = getNpmGlobalDir();
|
|
414
|
+
if (globalDir) {
|
|
415
|
+
const hDir = path.join(globalDir, 'node_modules', HARNESS_NPM_PACKAGE, '.harness');
|
|
416
|
+
tried.push(`npm全局 → ${hDir}`);
|
|
417
|
+
if (fs.existsSync(hDir) && fs.existsSync(path.join(hDir, 'rules'))) {
|
|
418
|
+
if (verbose) console.log(` 📂 源=npm全局: ${hDir}`);
|
|
419
|
+
return hDir;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
567
422
|
} catch { /* ignore */ }
|
|
568
423
|
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
// 递归搜索含 .harness/ 的 jsharness 包目录
|
|
582
|
-
const found = searchHarnessInDir(cacheRoot, 3);
|
|
583
|
-
if (found) return found;
|
|
424
|
+
// ── 策略 4: 从 npm registry 下载 tgz 并解压 ──
|
|
425
|
+
console.log(' ⬇️ 本地未找到 .harness/ 模板,从 npm 远端拉取...');
|
|
426
|
+
try {
|
|
427
|
+
const remoteDir = await downloadAndExtractFromNpm(options);
|
|
428
|
+
if (remoteDir) {
|
|
429
|
+
if (verbose) console.log(` 📂 源=npm远端: ${remoteDir}`);
|
|
430
|
+
return remoteDir;
|
|
431
|
+
}
|
|
432
|
+
tried.push('npm远端下载 → 失败');
|
|
433
|
+
} catch (err) {
|
|
434
|
+
tried.push(`npm远端 → ${err.message}`);
|
|
584
435
|
}
|
|
585
436
|
|
|
437
|
+
// ── 全部失败 ──
|
|
438
|
+
console.error('\n❌ 无法获取 .harness/ 模板。已尝试的路径:');
|
|
439
|
+
for (const t of tried) console.error(` • ${t}`);
|
|
440
|
+
console.error('');
|
|
441
|
+
console.error(' 请尝试:');
|
|
442
|
+
console.error(' npm install -g jsharness && jsharness init');
|
|
443
|
+
console.error(' 或检查网络连接后重试:');
|
|
444
|
+
console.error(' npx jsharness@latest init');
|
|
586
445
|
return null;
|
|
587
446
|
}
|
|
588
447
|
|
|
589
448
|
/**
|
|
590
|
-
*
|
|
449
|
+
* 获取 npm 全局安装根目录
|
|
591
450
|
*/
|
|
592
|
-
function
|
|
593
|
-
if (maxDepth <= 0) return null;
|
|
451
|
+
function getNpmGlobalDir() {
|
|
594
452
|
try {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
// 检查是否包含 .harness/rules/
|
|
601
|
-
const harnessDir = path.join(fullPath, '.harness');
|
|
602
|
-
if (fs.existsSync(path.join(harnessDir, 'rules'))) {
|
|
603
|
-
return harnessDir;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// 继续向下搜索
|
|
607
|
-
const found = searchHarnessInDir(fullPath, maxDepth - 1);
|
|
608
|
-
if (found) return found;
|
|
609
|
-
}
|
|
610
|
-
} catch { /* ignore permission errors */ }
|
|
611
|
-
return null;
|
|
453
|
+
return execSync('npm root -g', { encoding: 'utf-8' }).trim();
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
612
457
|
}
|
|
613
458
|
|
|
614
459
|
/**
|
|
@@ -617,66 +462,80 @@ function searchHarnessInDir(dir, maxDepth) {
|
|
|
617
462
|
async function downloadAndExtractFromNpm(options = {}) {
|
|
618
463
|
const { verbose = false } = options;
|
|
619
464
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (!packUrl) return null;
|
|
624
|
-
|
|
625
|
-
// 2. 下载 tgz 到临时目录
|
|
626
|
-
const tmpDir = path.join(process.env.TEMP || process.env.TMP || '/tmp', `jsharness-${Date.now()}`);
|
|
627
|
-
fs.mkdirSync(tmpDir, { recursive: true });
|
|
628
|
-
const tgzPath = path.join(tmpDir, 'jsharness.tgz');
|
|
465
|
+
// 1. 获取包的 tgz URL
|
|
466
|
+
const packUrl = await getNpmPackageTarballUrl();
|
|
467
|
+
if (!packUrl) return null;
|
|
629
468
|
|
|
630
|
-
|
|
631
|
-
|
|
469
|
+
// 2. 下载 tgz 到临时目录
|
|
470
|
+
const tmpDir = path.join(
|
|
471
|
+
process.env.TEMP || process.env.TMP || process.env.TMPDIR || '/tmp',
|
|
472
|
+
`jsharness-${Date.now()}`
|
|
473
|
+
);
|
|
474
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
475
|
+
const tgzPath = path.join(tmpDir, 'jsharness.tgz');
|
|
632
476
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
fs.mkdirSync(extractDir, { recursive: true });
|
|
477
|
+
if (verbose) console.log(` 📦 下载: ${packUrl}`);
|
|
478
|
+
await downloadFile(packUrl, tgzPath);
|
|
636
479
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
// tar 命令失败(某些 Windows 环境),使用 Node.js 解压
|
|
641
|
-
if (verbose) console.log(' 📦 tar 命令失败,使用 Node.js 解压...');
|
|
642
|
-
await extractTgzWithNode(tgzPath, extractDir);
|
|
643
|
-
}
|
|
480
|
+
// 3. 解压
|
|
481
|
+
const extractDir = path.join(tmpDir, 'extracted');
|
|
482
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
644
483
|
|
|
645
|
-
|
|
646
|
-
const harnessDir = path.join(extractDir, 'package', '.harness');
|
|
647
|
-
if (fs.existsSync(harnessDir)) return harnessDir;
|
|
484
|
+
extractTgz(tgzPath, extractDir, verbose);
|
|
648
485
|
|
|
649
|
-
|
|
650
|
-
|
|
486
|
+
// 4. 查找 .harness/ 目录
|
|
487
|
+
const harnessDir = path.join(extractDir, 'package', '.harness');
|
|
488
|
+
if (fs.existsSync(harnessDir) && fs.existsSync(path.join(harnessDir, 'rules'))) {
|
|
489
|
+
return harnessDir;
|
|
490
|
+
}
|
|
651
491
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
return null;
|
|
492
|
+
// 有些 tgz 没有 package/ 前缀
|
|
493
|
+
const altDir = path.join(extractDir, '.harness');
|
|
494
|
+
if (fs.existsSync(altDir) && fs.existsSync(path.join(altDir, 'rules'))) {
|
|
495
|
+
return altDir;
|
|
657
496
|
}
|
|
497
|
+
|
|
498
|
+
// 深度搜索
|
|
499
|
+
return searchHarnessInDir(extractDir, 3);
|
|
658
500
|
}
|
|
659
501
|
|
|
660
502
|
/**
|
|
661
|
-
*
|
|
662
|
-
* .tgz = gzip(tar),使用 zlib + tar 命令组合
|
|
503
|
+
* 解压 tgz 文件(跨平台兼容)
|
|
663
504
|
*/
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// 但 Windows 的 tar.exe 路径可能不同
|
|
667
|
-
const tarCommands = [
|
|
505
|
+
function extractTgz(tgzPath, destDir, verbose) {
|
|
506
|
+
const commands = [
|
|
668
507
|
`tar -xzf "${tgzPath}" -C "${destDir}"`,
|
|
669
|
-
`cmd /c "tar -xzf \\"${tgzPath}\\" -C \\"${destDir}\\""`,
|
|
670
508
|
];
|
|
509
|
+
// Windows 可能需要不同调用方式
|
|
510
|
+
if (process.platform === 'win32') {
|
|
511
|
+
commands.push(`cmd /c "tar -xzf \\"${tgzPath}\\" -C \\"${destDir}\\""`);
|
|
512
|
+
}
|
|
671
513
|
|
|
672
|
-
for (const cmd of
|
|
514
|
+
for (const cmd of commands) {
|
|
673
515
|
try {
|
|
674
|
-
execSync(cmd, { stdio: 'pipe' });
|
|
675
|
-
return;
|
|
676
|
-
} catch { /*
|
|
516
|
+
execSync(cmd, { stdio: verbose ? 'inherit' : 'pipe' });
|
|
517
|
+
return;
|
|
518
|
+
} catch { /* try next */ }
|
|
677
519
|
}
|
|
520
|
+
throw new Error('tar 解压失败,请确认系统安装了 tar 命令');
|
|
521
|
+
}
|
|
678
522
|
|
|
679
|
-
|
|
523
|
+
/**
|
|
524
|
+
* 在指定目录下递归搜索包含 .harness/rules/ 的目录
|
|
525
|
+
*/
|
|
526
|
+
function searchHarnessInDir(dir, maxDepth) {
|
|
527
|
+
if (maxDepth <= 0) return null;
|
|
528
|
+
try {
|
|
529
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
530
|
+
if (!entry.isDirectory()) continue;
|
|
531
|
+
const fullPath = path.join(dir, entry.name);
|
|
532
|
+
const rulesPath = path.join(fullPath, '.harness', 'rules');
|
|
533
|
+
if (fs.existsSync(rulesPath)) return path.join(fullPath, '.harness');
|
|
534
|
+
const found = searchHarnessInDir(fullPath, maxDepth - 1);
|
|
535
|
+
if (found) return found;
|
|
536
|
+
}
|
|
537
|
+
} catch { /* ignore */ }
|
|
538
|
+
return null;
|
|
680
539
|
}
|
|
681
540
|
|
|
682
541
|
/**
|
|
@@ -719,12 +578,16 @@ function downloadFile(url, destPath) {
|
|
|
719
578
|
res.pipe(file);
|
|
720
579
|
file.on('finish', () => { file.close(resolve); });
|
|
721
580
|
}).on('error', (err) => {
|
|
722
|
-
fs.unlinkSync(destPath);
|
|
581
|
+
try { fs.unlinkSync(destPath); } catch { /* ignore */ }
|
|
723
582
|
reject(err);
|
|
724
583
|
});
|
|
725
584
|
});
|
|
726
585
|
}
|
|
727
586
|
|
|
587
|
+
// ============================================================
|
|
588
|
+
// 扫描 Harness 源文件
|
|
589
|
+
// ============================================================
|
|
590
|
+
|
|
728
591
|
export function scanHarnessRules(harnessDir, stackFilter) {
|
|
729
592
|
const rulesDir = path.join(harnessDir, 'rules');
|
|
730
593
|
const results = [];
|
|
@@ -739,24 +602,15 @@ export function scanHarnessRules(harnessDir, stackFilter) {
|
|
|
739
602
|
// 技术栈过滤
|
|
740
603
|
if (stackFilter && stackFilter !== 'all') {
|
|
741
604
|
const lowerRel = relPath.toLowerCase();
|
|
742
|
-
if (stackFilter === 'vue3' && !lowerRel.includes('vue') && !lowerRel.includes('frontend') && !lowerRel.includes('global')) {
|
|
743
|
-
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
605
|
+
if (stackFilter === 'vue3' && !lowerRel.includes('vue') && !lowerRel.includes('frontend') && !lowerRel.includes('global') && !lowerRel.includes('web')) {
|
|
606
|
+
continue;
|
|
746
607
|
}
|
|
747
608
|
if (stackFilter === 'java' && !lowerRel.includes('java') && !lowerRel.includes('backend') && !lowerRel.includes('global')) {
|
|
748
|
-
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
609
|
+
continue;
|
|
751
610
|
}
|
|
752
611
|
}
|
|
753
612
|
|
|
754
|
-
results.push({
|
|
755
|
-
name: entry,
|
|
756
|
-
path: filePath,
|
|
757
|
-
category,
|
|
758
|
-
relativePath: relPath,
|
|
759
|
-
});
|
|
613
|
+
results.push({ name: entry, path: filePath, category, relativePath: relPath });
|
|
760
614
|
}
|
|
761
615
|
}
|
|
762
616
|
|
|
@@ -769,20 +623,12 @@ export function scanHarnessRules(harnessDir, stackFilter) {
|
|
|
769
623
|
export function scanHarnessSkills(harnessDir, stackFilter) {
|
|
770
624
|
const skillsDir = path.join(harnessDir, 'skills');
|
|
771
625
|
const results = [];
|
|
772
|
-
|
|
773
626
|
if (!fs.existsSync(skillsDir)) return results;
|
|
774
627
|
|
|
775
|
-
const
|
|
776
|
-
for (const entry of entries) {
|
|
628
|
+
for (const entry of fs.readdirSync(skillsDir).filter(f => f.endsWith('.md'))) {
|
|
777
629
|
const filePath = path.join(skillsDir, entry);
|
|
778
|
-
results.push({
|
|
779
|
-
name: entry,
|
|
780
|
-
path: filePath,
|
|
781
|
-
category: 'skill',
|
|
782
|
-
relativePath: path.relative(harnessDir, filePath),
|
|
783
|
-
});
|
|
630
|
+
results.push({ name: entry, path: filePath, category: 'skill', relativePath: path.relative(harnessDir, filePath) });
|
|
784
631
|
}
|
|
785
|
-
|
|
786
632
|
return results;
|
|
787
633
|
}
|
|
788
634
|
|
|
@@ -799,12 +645,10 @@ export function injectOutputs(projectDir, outputs, options = {}) {
|
|
|
799
645
|
const targetPath = path.join(projectDir, output.relativePath);
|
|
800
646
|
const targetDir = path.dirname(targetPath);
|
|
801
647
|
|
|
802
|
-
// 创建目录
|
|
803
648
|
if (!fs.existsSync(targetDir)) {
|
|
804
649
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
805
650
|
}
|
|
806
651
|
|
|
807
|
-
// 检查是否已存在
|
|
808
652
|
if (fs.existsSync(targetPath) && !force) {
|
|
809
653
|
skipped.push(output.relativePath);
|
|
810
654
|
if (verbose) console.log(` ⏭ 跳过 (已存在): ${output.relativePath}`);
|
|
@@ -819,45 +663,22 @@ export function injectOutputs(projectDir, outputs, options = {}) {
|
|
|
819
663
|
return { written, skipped };
|
|
820
664
|
}
|
|
821
665
|
|
|
822
|
-
// ============================================================
|
|
823
|
-
// CLI 命令实现
|
|
824
|
-
// ============================================================
|
|
825
|
-
|
|
826
666
|
// ============================================================
|
|
827
667
|
// 交互式问答辅助函数
|
|
828
668
|
// ============================================================
|
|
829
669
|
|
|
830
|
-
/**
|
|
831
|
-
* 通用 readline 提问
|
|
832
|
-
*/
|
|
833
670
|
function askQuestion(query) {
|
|
834
|
-
const rl = readline.createInterface({
|
|
835
|
-
input: process.stdin,
|
|
836
|
-
output: process.stdout,
|
|
837
|
-
});
|
|
671
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
838
672
|
return new Promise((resolve) => {
|
|
839
|
-
rl.question(query, (answer) => {
|
|
840
|
-
rl.close();
|
|
841
|
-
resolve(answer.trim());
|
|
842
|
-
});
|
|
673
|
+
rl.question(query, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
843
674
|
});
|
|
844
675
|
}
|
|
845
676
|
|
|
846
|
-
/**
|
|
847
|
-
* 交互式选择 AI 编程工具
|
|
848
|
-
*
|
|
849
|
-
* 先自动检测,检测结果让用户确认或修改;
|
|
850
|
-
* 检测不到则展示列表让用户选择(支持多选,逗号分隔)。
|
|
851
|
-
*
|
|
852
|
-
* @param {string} projectDir
|
|
853
|
-
* @returns {Promise<string[]>} 选中的工具 ID 列表
|
|
854
|
-
*/
|
|
855
677
|
async function promptSelectTools(projectDir) {
|
|
856
678
|
const detected = detectTool(projectDir);
|
|
857
679
|
|
|
858
680
|
console.log('━━━ 选择 AI 编程工具 ━━━\n');
|
|
859
681
|
|
|
860
|
-
// 展示所有支持的工具
|
|
861
682
|
SUPPORTED_TOOLS.forEach((t, i) => {
|
|
862
683
|
const isDetected = detected.some(d => d.id === t.id);
|
|
863
684
|
console.log(` ${String(i + 1).padStart(2)}. ${isDetected ? '✅' : '○ '} ${t.name}`);
|
|
@@ -873,18 +694,12 @@ async function promptSelectTools(projectDir) {
|
|
|
873
694
|
|
|
874
695
|
const answer = await askQuestion(' 请选择 (多选用逗号分隔,如 1,3): ');
|
|
875
696
|
|
|
876
|
-
|
|
877
|
-
if (!answer && detected.length > 0) {
|
|
878
|
-
return detected.map(t => t.id);
|
|
879
|
-
}
|
|
697
|
+
if (!answer && detected.length > 0) return detected.map(t => t.id);
|
|
880
698
|
|
|
881
|
-
// 解析用户输入的编号
|
|
882
699
|
const indices = answer.split(/[,,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n));
|
|
883
700
|
const selected = [];
|
|
884
701
|
for (const idx of indices) {
|
|
885
|
-
if (idx >= 1 && idx <= SUPPORTED_TOOLS.length)
|
|
886
|
-
selected.push(SUPPORTED_TOOLS[idx - 1].id);
|
|
887
|
-
}
|
|
702
|
+
if (idx >= 1 && idx <= SUPPORTED_TOOLS.length) selected.push(SUPPORTED_TOOLS[idx - 1].id);
|
|
888
703
|
}
|
|
889
704
|
|
|
890
705
|
if (selected.length === 0) {
|
|
@@ -895,11 +710,6 @@ async function promptSelectTools(projectDir) {
|
|
|
895
710
|
return selected;
|
|
896
711
|
}
|
|
897
712
|
|
|
898
|
-
/**
|
|
899
|
-
* 交互式选择技术栈
|
|
900
|
-
*
|
|
901
|
-
* @returns {Promise<string>} 'vue3' | 'java' | 'all'
|
|
902
|
-
*/
|
|
903
713
|
async function promptSelectStack() {
|
|
904
714
|
console.log('━━━ 选择项目技术栈 ━━━\n');
|
|
905
715
|
console.log(' 1. 🖥️ 前端 (Vue3 + TypeScript + Element Plus)');
|
|
@@ -907,7 +717,6 @@ async function promptSelectStack() {
|
|
|
907
717
|
console.log(' 3. 🔗 前后端一起\n');
|
|
908
718
|
|
|
909
719
|
const answer = await askQuestion(' 请选择 [1/2/3]: ');
|
|
910
|
-
|
|
911
720
|
const map = { '1': 'vue3', '2': 'java', '3': 'all' };
|
|
912
721
|
return map[answer] || 'all';
|
|
913
722
|
}
|
|
@@ -921,7 +730,9 @@ function stackLabel(stack) {
|
|
|
921
730
|
return labels[stack] || stack;
|
|
922
731
|
}
|
|
923
732
|
|
|
924
|
-
|
|
733
|
+
// ============================================================
|
|
734
|
+
// CLI 命令实现
|
|
735
|
+
// ============================================================
|
|
925
736
|
|
|
926
737
|
export async function runInit(projectDir, options = {}) {
|
|
927
738
|
const {
|
|
@@ -936,18 +747,9 @@ export async function runInit(projectDir, options = {}) {
|
|
|
936
747
|
console.log('\n🔧 Harness Engineering 初始化');
|
|
937
748
|
console.log(` 目标目录: ${projectDir}\n`);
|
|
938
749
|
|
|
939
|
-
// 1. 多源获取 .harness/
|
|
750
|
+
// 1. 多源获取 .harness/ 模板
|
|
940
751
|
const harnessDir = await getHarnessSourceDir({ verbose });
|
|
941
752
|
if (!harnessDir) {
|
|
942
|
-
console.error('❌ 无法获取 .harness/ 模板。');
|
|
943
|
-
console.error('');
|
|
944
|
-
console.error(' 已尝试:');
|
|
945
|
-
console.error(' 1. 本包自带的 .harness/ 目录');
|
|
946
|
-
console.error(' 2. npx 缓存目录');
|
|
947
|
-
console.error(' 3. npm registry 远端下载');
|
|
948
|
-
console.error('');
|
|
949
|
-
console.error(' 请检查网络连接,或手动安装后重试:');
|
|
950
|
-
console.error(' npm install -g jsharness && jsharness init');
|
|
951
753
|
process.exit(1);
|
|
952
754
|
}
|
|
953
755
|
if (verbose) console.log(` 📂 Harness 源: ${harnessDir}`);
|
|
@@ -973,15 +775,11 @@ export async function runInit(projectDir, options = {}) {
|
|
|
973
775
|
}
|
|
974
776
|
|
|
975
777
|
console.log(`\n📋 选中的 AI 工具:`);
|
|
976
|
-
for (const t of targetTools) {
|
|
977
|
-
console.log(` • ${t.name}`);
|
|
978
|
-
}
|
|
778
|
+
for (const t of targetTools) console.log(` • ${t.name}`);
|
|
979
779
|
|
|
980
780
|
// 3. 交互式选择技术栈
|
|
981
781
|
let stack = requestedStack;
|
|
982
|
-
if (!stack)
|
|
983
|
-
stack = await promptSelectStack();
|
|
984
|
-
}
|
|
782
|
+
if (!stack) stack = await promptSelectStack();
|
|
985
783
|
console.log(`\n🏗️ 技术栈: ${stackLabel(stack)}\n`);
|
|
986
784
|
|
|
987
785
|
// 4. 扫描源文件
|
|
@@ -1000,27 +798,21 @@ export async function runInit(projectDir, options = {}) {
|
|
|
1000
798
|
|
|
1001
799
|
const outputs = [];
|
|
1002
800
|
|
|
1003
|
-
// 注入 Rules
|
|
1004
801
|
if (!skillsOnly && allRuleFiles.length > 0) {
|
|
1005
802
|
console.log(`📜 注入规则 (${allRuleFiles.length} 个)...`);
|
|
1006
|
-
|
|
1007
|
-
outputs.push(...result.files);
|
|
803
|
+
outputs.push(...transformRules(allRuleFiles, tool.id, { stack }).files);
|
|
1008
804
|
}
|
|
1009
805
|
|
|
1010
|
-
// 注入 Skills
|
|
1011
806
|
if (!rulesOnly && allSkillFiles.length > 0) {
|
|
1012
807
|
console.log(`⚡ 注入技能 (${allSkillFiles.length} 个)...`);
|
|
1013
|
-
|
|
1014
|
-
outputs.push(...result.files);
|
|
808
|
+
outputs.push(...transformSkills(allSkillFiles, tool.id, { stack }).files);
|
|
1015
809
|
}
|
|
1016
810
|
|
|
1017
|
-
// 写入文件
|
|
1018
811
|
const { written, skipped } = injectOutputs(projectDir, outputs, { force, verbose });
|
|
1019
|
-
|
|
1020
812
|
summary.push({ tool: tool.name, written, skipped });
|
|
1021
813
|
}
|
|
1022
814
|
|
|
1023
|
-
// 6. 初始化 OpenSpec
|
|
815
|
+
// 6. 初始化 OpenSpec
|
|
1024
816
|
console.log('\n━━━ 初始化 OpenSpec ━━━');
|
|
1025
817
|
const openspecResult = initOpenSpec(projectDir, { force, verbose });
|
|
1026
818
|
if (openspecResult.created.length > 0) {
|
|
@@ -1034,7 +826,7 @@ export async function runInit(projectDir, options = {}) {
|
|
|
1034
826
|
// 7. 输出总结
|
|
1035
827
|
console.log('\n═════════════════════════════');
|
|
1036
828
|
console.log('✅ 初始化完成!');
|
|
1037
|
-
|
|
829
|
+
|
|
1038
830
|
for (const s of summary) {
|
|
1039
831
|
if (s.written.length > 0) {
|
|
1040
832
|
console.log(`\n [${s.tool}]`);
|
|
@@ -1062,40 +854,30 @@ export async function runInit(projectDir, options = {}) {
|
|
|
1062
854
|
|
|
1063
855
|
export function listTools() {
|
|
1064
856
|
console.log('\n支持的 AI 编程工具:\n');
|
|
1065
|
-
|
|
1066
857
|
for (const tool of SUPPORTED_TOOLS) {
|
|
1067
|
-
|
|
1068
|
-
console.log(`${status} ${tool.id.padEnd(14)} ${tool.name}`);
|
|
858
|
+
console.log(`○ ${tool.id.padEnd(14)} ${tool.name}`);
|
|
1069
859
|
console.log(` 格式: ${tool.ruleFormat}${tool.skillFormat ? ' + ' + tool.skillFormat : ''}`);
|
|
1070
|
-
console.log(` 说明: ${tool.description}`);
|
|
1071
|
-
console.log('');
|
|
860
|
+
console.log(` 说明: ${tool.description}\n`);
|
|
1072
861
|
}
|
|
1073
|
-
|
|
1074
862
|
console.log('使用方法:');
|
|
1075
|
-
console.log(' npx
|
|
1076
|
-
console.log('');
|
|
863
|
+
console.log(' npx jsharness init --tool <id>\n');
|
|
1077
864
|
}
|
|
1078
865
|
|
|
1079
866
|
export function showStatus(projectDir) {
|
|
1080
867
|
console.log('\n📊 Harness 项目状态\n');
|
|
1081
868
|
|
|
1082
|
-
// 检查 .harness 是否存在
|
|
1083
869
|
const harnessDir = path.join(projectDir, '.harness');
|
|
1084
870
|
const hasHarness = fs.existsSync(harnessDir);
|
|
1085
871
|
console.log(` Harness 源: ${hasHarness ? '✅ 存在' : '❌ 缺失'} (.harness/)`);
|
|
1086
872
|
|
|
1087
873
|
if (hasHarness) {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
console.log(` 规则数量: ${rulesCount}`);
|
|
1091
|
-
console.log(` 技能数量: ${skillsCount}`);
|
|
874
|
+
console.log(` 规则数量: ${scanHarnessRules(harnessDir, 'all').length}`);
|
|
875
|
+
console.log(` 技能数量: ${scanHarnessSkills(harnessDir, 'all').length}`);
|
|
1092
876
|
}
|
|
1093
877
|
|
|
1094
|
-
// 检查各工具状态
|
|
1095
878
|
const detected = detectTool(projectDir);
|
|
1096
879
|
console.log(` AI 工具: ${detected.length > 0 ? detected.map(t => t.name).join(', ') : '(未检测到)'}`);
|
|
1097
880
|
|
|
1098
|
-
// 检查已注入的目标文件
|
|
1099
881
|
const targets = [
|
|
1100
882
|
{ name: 'CodeBuddy', path: '.codebuddy/rules/' },
|
|
1101
883
|
{ name: 'Qoder', path: '.qoder/rules/' },
|
|
@@ -1111,54 +893,36 @@ export function showStatus(projectDir) {
|
|
|
1111
893
|
|
|
1112
894
|
console.log('\n 注入状态:');
|
|
1113
895
|
for (const t of targets) {
|
|
1114
|
-
const
|
|
1115
|
-
const exists = fs.existsSync(fullPath);
|
|
896
|
+
const exists = fs.existsSync(path.join(projectDir, t.path));
|
|
1116
897
|
console.log(` ${exists ? '✅' : '○ '} ${t.name.padEnd(12)} ${t.path}`);
|
|
1117
898
|
}
|
|
1118
899
|
|
|
1119
|
-
// OpenSpec 状态
|
|
1120
900
|
const openspecDir = path.join(projectDir, 'openspec');
|
|
1121
901
|
const hasOpenSpec = fs.existsSync(openspecDir);
|
|
1122
902
|
console.log(`\n OpenSpec: ${hasOpenSpec ? '✅ 已初始化' : '○ 未初始化'}`);
|
|
1123
903
|
if (hasOpenSpec) {
|
|
1124
904
|
const changesDir = path.join(openspecDir, 'changes');
|
|
1125
905
|
if (fs.existsSync(changesDir)) {
|
|
1126
|
-
const active = fs.readdirSync(changesDir, { withFileTypes: true })
|
|
1127
|
-
.filter(d => d.isDirectory() && d.name !== 'archive');
|
|
906
|
+
const active = fs.readdirSync(changesDir, { withFileTypes: true }).filter(d => d.isDirectory() && d.name !== 'archive');
|
|
1128
907
|
const archiveDir = path.join(changesDir, 'archive');
|
|
1129
|
-
const archived = fs.existsSync(archiveDir)
|
|
1130
|
-
? fs.readdirSync(archiveDir, { withFileTypes: true }).filter(d => d.isDirectory()).length
|
|
1131
|
-
: 0;
|
|
908
|
+
const archived = fs.existsSync(archiveDir) ? fs.readdirSync(archiveDir, { withFileTypes: true }).filter(d => d.isDirectory()).length : 0;
|
|
1132
909
|
console.log(` 活跃变更: ${active.length} 归档: ${archived}`);
|
|
1133
910
|
}
|
|
1134
911
|
}
|
|
1135
|
-
|
|
1136
912
|
console.log('');
|
|
1137
913
|
}
|
|
1138
914
|
|
|
1139
915
|
// ============================================================
|
|
1140
|
-
// OpenSpec
|
|
916
|
+
// OpenSpec 内聚初始化
|
|
1141
917
|
// ============================================================
|
|
1142
918
|
|
|
1143
|
-
const OPENSPEC_DIRS = [
|
|
1144
|
-
'openspec',
|
|
1145
|
-
'openspec/changes',
|
|
1146
|
-
'openspec/changes/archive',
|
|
1147
|
-
'openspec/specs',
|
|
1148
|
-
];
|
|
919
|
+
const OPENSPEC_DIRS = ['openspec', 'openspec/changes', 'openspec/changes/archive', 'openspec/specs'];
|
|
1149
920
|
|
|
1150
921
|
const OPENSPEC_GITIGNORE = `# OpenSpec generated files
|
|
1151
922
|
changes/*/dist/
|
|
1152
923
|
*.log
|
|
1153
924
|
`;
|
|
1154
925
|
|
|
1155
|
-
/**
|
|
1156
|
-
* 初始化 OpenSpec 目录结构到目标项目
|
|
1157
|
-
*
|
|
1158
|
-
* @param {string} projectDir - 目标项目根目录
|
|
1159
|
-
* @param {object} options - 选项 { force, verbose }
|
|
1160
|
-
* @returns {{ created: string[], skipped: string[] }}
|
|
1161
|
-
*/
|
|
1162
926
|
export function initOpenSpec(projectDir, options = {}) {
|
|
1163
927
|
const { force = false, verbose = false } = options;
|
|
1164
928
|
const created = [];
|
|
@@ -1176,30 +940,23 @@ export function initOpenSpec(projectDir, options = {}) {
|
|
|
1176
940
|
if (verbose) console.log(` ✅ 创建目录: ${dir}/`);
|
|
1177
941
|
}
|
|
1178
942
|
|
|
1179
|
-
// .gitkeep 确保空目录可被 git 追踪
|
|
1180
943
|
for (const dir of ['openspec/changes/archive', 'openspec/specs']) {
|
|
1181
944
|
const gitkeepPath = path.join(projectDir, dir, '.gitkeep');
|
|
1182
|
-
if (!fs.existsSync(gitkeepPath))
|
|
1183
|
-
fs.writeFileSync(gitkeepPath, '', 'utf-8');
|
|
1184
|
-
}
|
|
945
|
+
if (!fs.existsSync(gitkeepPath)) fs.writeFileSync(gitkeepPath, '', 'utf-8');
|
|
1185
946
|
}
|
|
1186
947
|
|
|
1187
|
-
// openspec/.gitignore
|
|
1188
948
|
const gitignorePath = path.join(projectDir, 'openspec', '.gitignore');
|
|
1189
949
|
if (!fs.existsSync(gitignorePath) || force) {
|
|
1190
950
|
fs.writeFileSync(gitignorePath, OPENSPEC_GITIGNORE, 'utf-8');
|
|
1191
951
|
if (!created.includes('openspec')) created.push('openspec/.gitignore');
|
|
1192
|
-
if (verbose) console.log(` ✅ 创建文件: openspec/.gitignore`);
|
|
1193
952
|
} else {
|
|
1194
953
|
skipped.push('openspec/.gitignore');
|
|
1195
954
|
}
|
|
1196
955
|
|
|
1197
|
-
// openspec/README.md
|
|
1198
956
|
const readmePath = path.join(projectDir, 'openspec', 'README.md');
|
|
1199
957
|
if (!fs.existsSync(readmePath) || force) {
|
|
1200
958
|
fs.writeFileSync(readmePath, generateOpenSpecReadme(), 'utf-8');
|
|
1201
959
|
created.push('openspec/README.md');
|
|
1202
|
-
if (verbose) console.log(` ✅ 创建文件: openspec/README.md`);
|
|
1203
960
|
} else {
|
|
1204
961
|
skipped.push('openspec/README.md');
|
|
1205
962
|
}
|
|
@@ -1233,13 +990,8 @@ openspec/
|
|
|
1233
990
|
## 命令行操作
|
|
1234
991
|
|
|
1235
992
|
\`\`\`bash
|
|
1236
|
-
# 查看状态
|
|
1237
993
|
npx jsharness status
|
|
1238
|
-
|
|
1239
|
-
# 列出活跃变更
|
|
1240
994
|
npx jsharness openspec list
|
|
1241
|
-
|
|
1242
|
-
# 归档变更
|
|
1243
995
|
npx jsharness openspec archive --change "<name>"
|
|
1244
996
|
\`\`\`
|
|
1245
997
|
|
|
@@ -1247,9 +999,6 @@ npx jsharness openspec archive --change "<name>"
|
|
|
1247
999
|
`;
|
|
1248
1000
|
}
|
|
1249
1001
|
|
|
1250
|
-
/**
|
|
1251
|
-
* 列出当前项目的 OpenSpec changes
|
|
1252
|
-
*/
|
|
1253
1002
|
export function listOpenSpecChanges(projectDir) {
|
|
1254
1003
|
const changesDir = path.join(projectDir, 'openspec', 'changes');
|
|
1255
1004
|
if (!fs.existsSync(changesDir)) {
|
|
@@ -1257,8 +1006,7 @@ export function listOpenSpecChanges(projectDir) {
|
|
|
1257
1006
|
return;
|
|
1258
1007
|
}
|
|
1259
1008
|
|
|
1260
|
-
const entries = fs.readdirSync(changesDir, { withFileTypes: true })
|
|
1261
|
-
.filter(d => d.isDirectory() && d.name !== 'archive');
|
|
1009
|
+
const entries = fs.readdirSync(changesDir, { withFileTypes: true }).filter(d => d.isDirectory() && d.name !== 'archive');
|
|
1262
1010
|
|
|
1263
1011
|
if (entries.length === 0) {
|
|
1264
1012
|
console.log('\n📋 没有活跃的 OpenSpec 变更\n');
|
|
@@ -1281,21 +1029,15 @@ export function listOpenSpecChanges(projectDir) {
|
|
|
1281
1029
|
|
|
1282
1030
|
const archiveDir = path.join(changesDir, 'archive');
|
|
1283
1031
|
if (fs.existsSync(archiveDir)) {
|
|
1284
|
-
const archived = fs.readdirSync(archiveDir, { withFileTypes: true })
|
|
1285
|
-
.filter(d => d.isDirectory());
|
|
1032
|
+
const archived = fs.readdirSync(archiveDir, { withFileTypes: true }).filter(d => d.isDirectory());
|
|
1286
1033
|
if (archived.length > 0) {
|
|
1287
1034
|
console.log('\n📦 已归档:');
|
|
1288
|
-
for (const entry of archived) {
|
|
1289
|
-
console.log(` • ${entry.name}`);
|
|
1290
|
-
}
|
|
1035
|
+
for (const entry of archived) console.log(` • ${entry.name}`);
|
|
1291
1036
|
}
|
|
1292
1037
|
}
|
|
1293
1038
|
console.log('');
|
|
1294
1039
|
}
|
|
1295
1040
|
|
|
1296
|
-
/**
|
|
1297
|
-
* 归档一个 OpenSpec change
|
|
1298
|
-
*/
|
|
1299
1041
|
export function archiveOpenSpecChange(projectDir, changeName) {
|
|
1300
1042
|
const changesDir = path.join(projectDir, 'openspec', 'changes');
|
|
1301
1043
|
const sourceDir = path.join(changesDir, changeName);
|
|
@@ -1310,5 +1052,3 @@ export function archiveOpenSpecChange(projectDir, changeName) {
|
|
|
1310
1052
|
fs.renameSync(sourceDir, archiveDir);
|
|
1311
1053
|
console.log(`\n✅ 变更 "${changeName}" 已归档到 openspec/changes/archive/${path.basename(archiveDir)}/\n`);
|
|
1312
1054
|
}
|
|
1313
|
-
|
|
1314
|
-
|