psmgr 1.0.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/bin/psm.js ADDED
@@ -0,0 +1,2004 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================
3
+ // psm — Project Skills Manager CLI
4
+ // ============================================================
5
+ // npx psmgr install [-y] [--preview] [target]
6
+ // npx psmgr check [target]
7
+ // npx psmgr info [target]
8
+ // npx psmgr list
9
+ // npx psmgr outdated
10
+ // npx psmgr update
11
+ // npx psmgr version
12
+ // npx psmgr --help
13
+ // ============================================================
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import readline from 'node:readline';
18
+ import { execSync } from 'node:child_process';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ // ---- Paths ----
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const PKG_DIR = path.resolve(__filename, '../..');
25
+ const AGENTS_SRC = path.join(PKG_DIR, '.agents');
26
+ const SCRIPTS_SRC = path.join(PKG_DIR, 'scripts');
27
+ const PKG_JSON = path.join(PKG_DIR, 'package.json');
28
+
29
+ const manifest = JSON.parse(fs.readFileSync(PKG_JSON, 'utf-8'));
30
+ const VERSION = manifest.version;
31
+
32
+ // ---- Exit codes ----
33
+
34
+ const EXIT = {
35
+ OK: 0,
36
+ ERR_UNKNOWN: 1,
37
+ ERR_NOT_INSTALLED: 2,
38
+ ERR_ALREADY_INSTALLED: 3,
39
+ ERR_NO_TARGET: 4,
40
+ ERR_OUTDATED: 10,
41
+ };
42
+
43
+ // ---- Colour helpers ----
44
+
45
+ function colour(code, text) {
46
+ return process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text;
47
+ }
48
+ const green = (s) => colour('0;32', `✔ ${s}`);
49
+ const cyan = (s) => colour('0;36', `ℹ ${s}`);
50
+ const yellow = (s) => colour('1;33', `⚡ ${s}`);
51
+ const red = (s) => colour('0;31', `✘ ${s}`);
52
+ const dim = (s) => colour('2', s);
53
+
54
+ // ---- Helpers ----
55
+
56
+ function die(msg, code = EXIT.ERR_UNKNOWN) {
57
+ console.error(red(msg));
58
+ process.exit(code);
59
+ }
60
+
61
+ /**
62
+ * Read the skills registry JSON from the package.
63
+ * Returns the parsed registry object, or null if not found.
64
+ */
65
+ function readRegistry() {
66
+ const registryPath = path.join(PKG_DIR, '.agents', 'skills-registry.json');
67
+ if (!fs.existsSync(registryPath)) return null;
68
+ try {
69
+ return JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Read user's custom skills-config.json from the target project.
77
+ * Returns an object with customSources/ignore/alwaysInstall, or empty defaults.
78
+ */
79
+ function readUserConfig(target) {
80
+ const configPath = path.join(target || '.', '.agents', 'skills-config.json');
81
+ if (!fs.existsSync(configPath)) return { customSources: [], ignore: [], alwaysInstall: [] };
82
+ try {
83
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
84
+ } catch {
85
+ return { customSources: [], ignore: [], alwaysInstall: [] };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create a timestamped backup of a path before modifying it.
91
+ * Returns the backup path or null if nothing was backed up.
92
+ */
93
+ function backupPath(target, relPath) {
94
+ const source = path.join(target, relPath);
95
+ if (!fs.existsSync(source)) return null;
96
+
97
+ const ts = Date.now();
98
+ const backupDir = path.join(target, '.agents', '.psm-backup', String(ts));
99
+ const dest = path.join(backupDir, relPath);
100
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
101
+
102
+ if (fs.statSync(source).isDirectory()) {
103
+ copyFileSyncSimple(source, dest); // shallow copy for backup
104
+ } else {
105
+ fs.copyFileSync(source, dest);
106
+ }
107
+ return dest;
108
+ }
109
+
110
+ function copyFileSyncSimple(src, dest) {
111
+ fs.mkdirSync(dest, { recursive: true });
112
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
113
+ const s = path.join(src, entry.name);
114
+ const d = path.join(dest, entry.name);
115
+ if (entry.isDirectory()) {
116
+ copyFileSyncSimple(s, d);
117
+ } else {
118
+ fs.copyFileSync(s, d);
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Copy a single file with conflict resolution.
125
+ * autoYes=true → replaces silently (--yes mode).
126
+ * Returns 'replaced' | 'kept' | 'copied'.
127
+ */
128
+ async function safeCopyFile(src, dest, label, autoYes = false) {
129
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
130
+
131
+ if (!fs.existsSync(dest)) {
132
+ fs.copyFileSync(src, dest);
133
+ return 'copied';
134
+ }
135
+
136
+ // File already exists → conflict
137
+ if (autoYes) {
138
+ // --yes mode: auto-replace (but still backup first)
139
+ fs.copyFileSync(src, dest);
140
+ return 'replaced';
141
+ }
142
+
143
+ const srcContent = fs.readFileSync(src, 'utf-8');
144
+ const dstContent = fs.readFileSync(dest, 'utf-8');
145
+
146
+ // Identical content → skip
147
+ if (srcContent === dstContent) {
148
+ return 'kept';
149
+ }
150
+
151
+ console.log(`\n${yellow('═══ File Conflict ═══')}`);
152
+ console.log(` File: ${label}`);
153
+ const choice = await askChoice(
154
+ '如何处理此文件?',
155
+ [
156
+ '替换为 psm 版本(原文件备份到 .agents/.psm-backup/)',
157
+ '保留现有文件,不替换',
158
+ '显示差异(查看后再选)',
159
+ ],
160
+ );
161
+
162
+ if (choice === 0) {
163
+ backupPath(path.dirname(dest), path.basename(dest)); // backup old version
164
+ fs.copyFileSync(src, dest);
165
+ return 'replaced';
166
+ } else if (choice === 2) {
167
+ // Show diff
168
+ const srcLines = srcContent.split('\n');
169
+ const dstLines = dstContent.split('\n');
170
+ console.log(`\n${dim('--- psm version (new)')}`);
171
+ console.log(`${dim('+++ current file (existing)')}`);
172
+ const max = Math.max(srcLines.length, dstLines.length);
173
+ for (let i = 0; i < max; i++) {
174
+ if (srcLines[i] !== dstLines[i]) {
175
+ const lineNum = i + 1;
176
+ if (srcLines[i] !== undefined) console.log(`${green('+')} ${dim(`L${lineNum}:`)} ${srcLines[i]}`);
177
+ if (dstLines[i] !== undefined) console.log(`${red('-')} ${dim(`L${lineNum}:`)} ${dstLines[i]}`);
178
+ }
179
+ }
180
+ // Re-ask
181
+ return safeCopyFile(src, dest, label, autoYes);
182
+ }
183
+
184
+ return 'kept';
185
+ }
186
+
187
+ /**
188
+ * Recursive copy with per-file conflict resolution.
189
+ */
190
+ async function safeCopyRecursive(src, dest, baseLabel, autoYes = false) {
191
+ let replaced = 0;
192
+ let kept = 0;
193
+ let copied = 0;
194
+
195
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
196
+ const s = path.join(src, entry.name);
197
+ const d = path.join(dest, entry.name);
198
+ const label = `${baseLabel}/${entry.name}`;
199
+
200
+ if (entry.isDirectory()) {
201
+ fs.mkdirSync(d, { recursive: true });
202
+ const sub = await safeCopyRecursive(s, d, label, autoYes);
203
+ replaced += sub.replaced;
204
+ kept += sub.kept;
205
+ copied += sub.copied;
206
+ } else {
207
+ const result = await safeCopyFile(s, d, label, autoYes);
208
+ if (result === 'replaced') replaced++;
209
+ else if (result === 'kept') kept++;
210
+ else copied++;
211
+ }
212
+ }
213
+
214
+ return { replaced, kept, copied };
215
+ }
216
+
217
+ function isInstalled(target) {
218
+ return fs.existsSync(path.join(target, '.agents', 'skills', 'INDEX.md'));
219
+ }
220
+
221
+ function getLatestVersion() {
222
+ try {
223
+ const out = execSync('npm view psmgr version', {
224
+ encoding: 'utf-8',
225
+ timeout: 5000,
226
+ stdio: ['ignore', 'pipe', 'pipe'],
227
+ });
228
+ return out.trim();
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ // ---- Prompt ----
235
+
236
+ function askYesNo(question, defaultYes = true) {
237
+ const hint = defaultYes ? '(Y/n)' : '(y/N)';
238
+ return new Promise((resolve) => {
239
+ const rl = readline.createInterface({
240
+ input: process.stdin,
241
+ output: process.stdout,
242
+ });
243
+ rl.question(`${cyan(`? ${question} ${hint}`)} `, (answer) => {
244
+ rl.close();
245
+ const a = answer.trim().toLowerCase();
246
+ if (a === '') resolve(defaultYes);
247
+ else if (a === 'y' || a === 'yes') resolve(true);
248
+ else resolve(false);
249
+ });
250
+ });
251
+ }
252
+
253
+ function askChoice(question, options) {
254
+ return new Promise((resolve) => {
255
+ const rl = readline.createInterface({
256
+ input: process.stdin,
257
+ output: process.stdout,
258
+ });
259
+ console.log(`\n${cyan(`? ${question}`)}`);
260
+ for (let i = 0; i < options.length; i++) {
261
+ console.log(` ${i + 1}) ${options[i]}`);
262
+ }
263
+ rl.question(` ${cyan('选择 (1-' + options.length + ')')}: `, (answer) => {
264
+ rl.close();
265
+ const idx = parseInt(answer.trim(), 10);
266
+ if (idx >= 1 && idx <= options.length) {
267
+ resolve(idx - 1);
268
+ } else {
269
+ resolve(0); // default = first option
270
+ }
271
+ });
272
+ });
273
+ }
274
+
275
+ // ---- Project Scanning ----
276
+
277
+ function detectProjectType(target) {
278
+ for (const [file, label] of [
279
+ ['package.json', 'Node.js / Frontend'],
280
+ ['pyproject.toml', 'Python'],
281
+ ['requirements.txt', 'Python'],
282
+ ['Cargo.toml', 'Rust'],
283
+ ['go.mod', 'Go'],
284
+ ]) {
285
+ if (fs.existsSync(path.join(target, file))) return label;
286
+ }
287
+ return 'unknown';
288
+ }
289
+
290
+ function scanExistingAiDocs(target) {
291
+ const docs = {};
292
+ const agentsPath = path.join(target, 'AGENTS.md');
293
+ const claudePath = path.join(target, 'CLAUDE.md');
294
+
295
+ if (fs.existsSync(agentsPath)) {
296
+ docs.AGENTS = fs.readFileSync(agentsPath, 'utf-8');
297
+ }
298
+ if (fs.existsSync(claudePath)) {
299
+ docs.CLAUDE = fs.readFileSync(claudePath, 'utf-8');
300
+ }
301
+
302
+ // Scan .cursor/rules/
303
+ const cursorRules = path.join(target, '.cursor', 'rules');
304
+ if (fs.existsSync(cursorRules)) {
305
+ docs.cursorRules = fs.readdirSync(cursorRules).filter((f) => f.endsWith('.mdc'));
306
+ }
307
+
308
+ return docs;
309
+ }
310
+
311
+ function scanExistingSkillDirs(target) {
312
+ const dirs = [];
313
+ for (const dir of ['.trae/skills', '.reasonix/skills', '.skills', 'skills', '.cursor/rules']) {
314
+ const full = path.join(target, dir);
315
+ if (fs.existsSync(full)) {
316
+ const entries = fs.readdirSync(full, { withFileTypes: true });
317
+ const skills = entries
318
+ .filter((e) => e.isDirectory())
319
+ .map((e) => e.name);
320
+ if (skills.length > 0) {
321
+ dirs.push({ path: dir, skills });
322
+ }
323
+ }
324
+ }
325
+ return dirs;
326
+ }
327
+
328
+ /**
329
+ * Scan content for version management sections.
330
+ * Returns array of matched section headers.
331
+ */
332
+ function scanVersionManagement(content) {
333
+ const patterns = [
334
+ /版本管理/i,
335
+ /版本更新/i,
336
+ /版本号/i,
337
+ /versioning/i,
338
+ /更新日志/i,
339
+ /changelog/i,
340
+ /version management/i,
341
+ /release\s*process/i,
342
+ ];
343
+ const matches = [];
344
+ const lines = content.split('\n');
345
+ for (let i = 0; i < lines.length; i++) {
346
+ const line = lines[i];
347
+ // Match headings or list items containing version-related keywords
348
+ if (/^#{1,4}\s/.test(line) || /^[-*]\s/.test(line)) {
349
+ for (const p of patterns) {
350
+ if (p.test(line)) {
351
+ matches.push({ line: i + 1, text: line.trim() });
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ }
357
+ return matches;
358
+ }
359
+
360
+ /**
361
+ * Scan content for code standards sections.
362
+ */
363
+ function scanCodeStandards(content) {
364
+ const patterns = [
365
+ /代码规范/i,
366
+ /编码规范/i,
367
+ /提交规范/i,
368
+ /commit\s*(message|convention|规范)/i,
369
+ /code\s*style/i,
370
+ /coding\s*standard/i,
371
+ /代码风格/i,
372
+ /lint/i,
373
+ ];
374
+ const matches = [];
375
+ const lines = content.split('\n');
376
+ for (let i = 0; i < lines.length; i++) {
377
+ const line = lines[i];
378
+ if (/^#{1,4}\s/.test(line) || /^[-*]\s/.test(line)) {
379
+ for (const p of patterns) {
380
+ if (p.test(line)) {
381
+ matches.push({ line: i + 1, text: line.trim() });
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ }
387
+ return matches;
388
+ }
389
+
390
+ /**
391
+ * Check if AGENTS.md already has psm skill tree entry injected.
392
+ */
393
+ function hasLoadingChain(content) {
394
+ return /psm 技能树入口/.test(content);
395
+ }
396
+
397
+ /**
398
+ * Check if CLAUDE.md already has @AGENTS.md reference.
399
+ */
400
+ function hasAgentsRef(content) {
401
+ return /@AGENTS\.md/.test(content);
402
+ }
403
+
404
+ // ---- INDEX.md Generation ----
405
+
406
+ function generateIndexMd(target) {
407
+ var NL = String.fromCharCode(92,110);
408
+ var BK = String.fromCharCode(96);
409
+ var Q = String.fromCharCode(34);
410
+ const agentsSkills = path.join(target, ".agents", "skills");
411
+ const agentsRules = path.join(target, ".agents", "rules");
412
+ const skills = [];
413
+ if (fs.existsSync(agentsSkills)) {
414
+ for (const entry of fs.readdirSync(agentsSkills, { withFileTypes: true })) {
415
+ if (!entry.isDirectory()) continue;
416
+ const skillMd = path.join(agentsSkills, entry.name, "SKILL.md");
417
+ if (!fs.existsSync(skillMd)) continue;
418
+ const content = fs.readFileSync(skillMd, "utf-8");
419
+ const name = content.match(/^name:s*(.+)/m)?.[1]?.trim() || entry.name;
420
+ const desc = content.match(/^description:s*(.+)/m)?.[1]?.trim() || "(no description)";
421
+ const treePath = content.match(/^tree:s*(.+)/m)?.[1]?.trim() || "";
422
+ skills.push({ dir: entry.name, name, desc, treePath });
423
+ }
424
+ }
425
+ const labels = { lifecycle: "🔧 生命周期管理", schedule: "📋 任务调度", release: "📦 版本发布" };
426
+ const treeMap = {};
427
+ for (const s of skills) {
428
+ if (s.treePath === "root") continue;
429
+ const parts = s.treePath.split("/");
430
+ const l1Key = parts[0] || "other";
431
+ if (!treeMap[l1Key]) treeMap[l1Key] = { label: labels[l1Key] || l1Key, children: [] };
432
+ treeMap[l1Key].children.push({ dir: s.dir, name: s.name, desc: s.desc });
433
+ }
434
+ const l1Keys = Object.keys(treeMap);
435
+ let md = "# 技能树 INDEX" + NL + NL;
436
+ md += "> 🎯**任何 AI Agent / AI IDE 的统一导航入口** — 安装后优先读取此文件了解技能结构" + NL;
437
+ md += "> 由 psm install 自动生成 — 运行 " + BK + "npx psmgr install --yes" + BK + " 重新生成" + NL + NL;
438
+ md += "---" + NL + NL;
439
+ md += "## 🌳 技能树总览" + NL + NL + BK + BK + BK + NL;
440
+ md += "L0: managing-project-skills(根节点 — 用户入口)" + NL + " │" + NL;
441
+ for (let i = 0; i < l1Keys.length; i++) {
442
+ const node = treeMap[l1Keys[i]];
443
+ const isLast = i === l1Keys.length - 1;
444
+ md += (isLast ? " └── " : " ├── ") + "L1: " + node.label + NL;
445
+ for (let j = 0; j < node.children.length; j++) {
446
+ const c = node.children[j];
447
+ const cJoin = j === node.children.length - 1 ? " └── " : " │ ├── ";
448
+ md += cJoin + "L2: " + c.name + " — " + c.desc.slice(0, 60) + NL;
449
+ }
450
+ }
451
+ md += BK + BK + BK + NL + NL;
452
+ md += "---" + NL + NL;
453
+ md += "## 🛭 导航指南(任何 Agent 通用)" + NL + NL;
454
+ md += "### 1. 用户发来消息 → 2. 匹配 L1 类别 → 3. 加载 L2 技能" + NL + NL;
455
+ md += "| 用户说/场景 | → 匹配 L1 | → 加载 L2 技能 |" + NL + "|------------|----------|---------------|" + NL;
456
+ const triggers = { "installing-project-skills": "“安装/更新/卸载/查看技能”", "scheduling-project-skills": "多技能编排/判断难度", "generating-changelogs": "“更新更新日志为 vx.x.x”" };
457
+ for (const key of l1Keys) {
458
+ for (const child of treeMap[key].children) {
459
+ const trigger = triggers[child.dir] || "相关任务";
460
+ md += "| " + trigger + " | " + treeMap[key].label + " | " + BK + child.dir + BK + " |" + NL;
461
+ }
462
+ }
463
+ md += "| “版本管理” / “版本规范” | 📦 版本发布 | 检查 " + BK + "version-management-rules.md" + BK + " |" + NL + NL;
464
+ md += "> **调用方式:** 任何 Agent 读取此表后,根据用户输入匹配第二列 L1 类别,然后直接读取 L2 对应的 SKILL.md 文件并执行。" + NL + NL;
465
+ md += "---" + NL + NL;
466
+ md += "## 📂 文件索引" + NL + NL + "| 文件 | 树路径 | 用途 |" + NL + "|------|-------|------|" + NL;
467
+ md += "| .agents/skills/managing-project-skills/SKILL.md | root | 根节点,L0 入口调度 |" + NL;
468
+ for (const s of skills) {
469
+ if (s.treePath === "root") continue;
470
+ md += "| " + s.dir + "/SKILL.md | " + s.treePath + " | " + s.desc.slice(0, 60) + " |" + NL;
471
+ }
472
+ md += "---" + NL + NL;
473
+ md += "## ⚡ 按需加载决策表" + NL + NL + "| 触发条件 | 操作 | 加载方式 |" + NL + "|---------|------|---------|" + NL;
474
+ md += "| 用户说「更新更新日志」「发布」「打 tag」 | 读取 version-management-rules.md 并应用 | 按需加载 |" + NL;
475
+ md += "| 用户说「修改代码」「新增」「提交代码」 | 读取 code-standards-rules.md 检查规范 | 按需加载 |" + NL;
476
+ md += "| 用户说「安装/更新/卸载技能」 | 读取 skill-lifecycle-rules.md 执行生命周期 | 按需加载 |" + NL;
477
+ md += "| 技能执行时编排任务 | 读取 skill-scheduling-rules.md 决定调度策略 | 按需加载 |" + NL;
478
+ md += "| 用户说「更新更新日志为 vx.x.x」 | 读取 changelog-rules.md 生成更新日志 | 按需加载 |" + NL;
479
+ md += "| 未匹配以上条件 | 仅使用 project-rules.md(已全量加载) | 全量加载 |" + NL + NL;
480
+ md += "> **注意:** 本 INDEX.md 由 psm 维护。任何 AI IDE 均可通过读取此文件理解技能树结构。" + NL;
481
+ return md;
482
+ }
483
+
484
+ // ---- Project Rules Generation ----
485
+
486
+ /**
487
+ * Mapping from project type (as returned by detectProjectType) to
488
+ * tech-stack code standards template file.
489
+ */
490
+ const TECH_STACK_TEMPLATES = {
491
+ 'Node.js / Frontend': 'code-standards-node.md',
492
+ 'Python': 'code-standards-python.md',
493
+ 'Rust': 'code-standards-rust.md',
494
+ 'Go': 'code-standards-go.md',
495
+ };
496
+
497
+ /**
498
+ * Generate a tech-stack-aware project-rules.md for the target project.
499
+ * Combines the base template with the matching code standards snippet.
500
+ */
501
+ function generateProjectRules(target) {
502
+ const templateDir = path.join(PKG_DIR, '.agents', 'rules', 'templates');
503
+ const baseTemplate = path.join(templateDir, 'project-rules-base.md');
504
+
505
+ if (!fs.existsSync(baseTemplate)) {
506
+ console.log(yellow(' project-rules.md 模板未找到,跳过动态生成'));
507
+ return null;
508
+ }
509
+
510
+ let content = fs.readFileSync(baseTemplate, 'utf-8');
511
+ const projectType = detectProjectType(target);
512
+ const snippetName = TECH_STACK_TEMPLATES[projectType];
513
+
514
+ if (snippetName) {
515
+ const snippetPath = path.join(templateDir, snippetName);
516
+ if (fs.existsSync(snippetPath)) {
517
+ const snippet = fs.readFileSync(snippetPath, 'utf-8');
518
+ content = content.replace('<!-- psm:code-standards -->', snippet);
519
+ console.log(green(` project-rules.md → ${projectType} 规范已注入`));
520
+ }
521
+ } else {
522
+ // Unknown project type: inject a minimal generic section
523
+ const generic = `<!-- psm:tech-stack-rules -->\n### 代码规范\n\n- 遵循项目已有代码风格(由 linters/formatters 强制执行)\n- 错误处理分级:用户可见错误 / 控制台日志 / 静默容错\n- 用户输入必须校验\n- 不硬编码密钥,通过环境变量注入\n`;
524
+ content = content.replace('<!-- psm:code-standards -->', generic);
525
+ console.log(yellow(` project-rules.md → ${projectType},使用通用规范`));
526
+ }
527
+
528
+ return content;
529
+ }
530
+
531
+ // ---- Rule Extraction (AGENTS.md + CLAUDE.md) ----
532
+
533
+ /**
534
+ * Load psm default rule content for comparison.
535
+ */
536
+ function getDefaultRuleContent(sectionType) {
537
+ const ruleFile = sectionType === 'version' ? 'version-management-rules.md' : 'code-standards-rules.md';
538
+ const pkgRulePath = path.join(PKG_DIR, '.agents', 'rules', ruleFile);
539
+ if (!fs.existsSync(pkgRulePath)) return '';
540
+ return fs.readFileSync(pkgRulePath, 'utf-8');
541
+ }
542
+
543
+ /**
544
+ * Check if user content overlaps with psm default rules.
545
+ * Returns a similarity score 0-1 based on keyword overlap.
546
+ */
547
+ function calcRuleOverlap(userContent, defaultContent) {
548
+ const extractKeywords = (text) => {
549
+ const tokens = text.toLowerCase()
550
+ .replace(/[#*`\-_>|\[\]]/g, ' ')
551
+ .split(/\s+/)
552
+ .filter((t) => t.length > 2);
553
+ return new Set(tokens);
554
+ };
555
+
556
+ const userKeys = extractKeywords(userContent);
557
+ const defaultKeys = extractKeywords(defaultContent);
558
+ if (userKeys.size === 0 || defaultKeys.size === 0) return 0;
559
+
560
+ let overlap = 0;
561
+ for (const k of userKeys) {
562
+ if (defaultKeys.has(k)) overlap++;
563
+ }
564
+ return overlap / Math.max(userKeys.size, defaultKeys.size);
565
+ }
566
+
567
+ /**
568
+ * Scan a single file (AGENTS.md or CLAUDE.md) for matching rule sections.
569
+ * Returns array of { header, content, sourceFile }.
570
+ */
571
+ function scanFileForSections(filePath, sectionType) {
572
+ if (!fs.existsSync(filePath)) return [];
573
+
574
+ const content = fs.readFileSync(filePath, 'utf-8');
575
+ const lines = content.split('\n');
576
+
577
+ let headingPattern;
578
+ if (sectionType === 'version') {
579
+ headingPattern = /^#{1,3}\s*(版本管理|版本更新|版本号|versioning|更新日志|changelog|version management|release\s*process)/i;
580
+ } else if (sectionType === 'standards') {
581
+ headingPattern = /^#{1,3}\s*(代码规范|编码规范|提交规范|commit\s*(message|convention)|code\s*style|coding\s*standard|代码风格)/i;
582
+ } else {
583
+ return [];
584
+ }
585
+
586
+ const sections = [];
587
+ let i = 0;
588
+
589
+ while (i < lines.length) {
590
+ if (headingPattern.test(lines[i])) {
591
+ const header = lines[i].trim();
592
+ const sectionLines = [lines[i]];
593
+ i++;
594
+ while (i < lines.length) {
595
+ if (/^#{1,3}\s/.test(lines[i]) && !/^#{4,}\s/.test(lines[i])) break;
596
+ sectionLines.push(lines[i]);
597
+ i++;
598
+ }
599
+ sections.push({
600
+ header,
601
+ content: sectionLines.join('\n'),
602
+ sourceFile: path.basename(filePath),
603
+ });
604
+ } else {
605
+ i++;
606
+ }
607
+ }
608
+
609
+ return sections;
610
+ }
611
+
612
+ /**
613
+ * Extract rule sections from AGENTS.md AND CLAUDE.md — ONE SECTION AT A TIME,
614
+ * with similarity comparison against psm defaults.
615
+ * Returns array of extracted section contents.
616
+ */
617
+ async function extractSectionsInteractive(target, sectionType, autoYes = false) {
618
+ const agentPath = path.join(target, 'AGENTS.md');
619
+ const claudePath = path.join(target, 'CLAUDE.md');
620
+
621
+ const typeLabel = sectionType === 'version' ? '版本管理' : '代码规范';
622
+ const ruleFile = sectionType === 'version' ? 'version-management-rules.md' : 'code-standards-rules.md';
623
+
624
+ // Scan both files
625
+ const agSections = scanFileForSections(agentPath, sectionType);
626
+ const clSections = scanFileForSections(claudePath, sectionType);
627
+ const allSections = [...agSections, ...clSections];
628
+
629
+ if (allSections.length === 0) return [];
630
+
631
+ // Load psm default for comparison
632
+ const defaultContent = getDefaultRuleContent(sectionType);
633
+
634
+ if (autoYes) {
635
+ // --yes: extract all, replace with references (from AGENTS.md only)
636
+ const extracted = allSections.map((s) => s.content);
637
+ if (agSections.length > 0) {
638
+ // Replace in AGENTS.md
639
+ let agContent = fs.readFileSync(agentPath, 'utf-8');
640
+ for (const s of agSections) {
641
+ const refText = `\n> **${typeLabel}:** 详见 \`.agents/rules/${ruleFile}\`(按需加载)\n`;
642
+ agContent = agContent.replace(s.content, refText);
643
+ }
644
+ fs.writeFileSync(agentPath, agContent, 'utf-8');
645
+ }
646
+ return extracted;
647
+ }
648
+
649
+ // Backup both files
650
+ const backupDir = path.join(target, '.agents', '.psm-backup');
651
+ if (!fs.existsSync(backupDir)) {
652
+ fs.mkdirSync(backupDir, { recursive: true });
653
+ }
654
+ if (fs.existsSync(agentPath)) {
655
+ fs.copyFileSync(agentPath, path.join(backupDir, 'AGENTS.md'));
656
+ }
657
+ if (fs.existsSync(claudePath)) {
658
+ fs.copyFileSync(claudePath, path.join(backupDir, 'CLAUDE.md'));
659
+ }
660
+
661
+ // Read current content (might be modified between sections)
662
+ let agContent = fs.existsSync(agentPath) ? fs.readFileSync(agentPath, 'utf-8') : '';
663
+ const extracted = [];
664
+
665
+ // Process each section one by one
666
+ for (const section of allSections) {
667
+ console.log(`\n${yellow(`═══════════════════════════════════════`)}`);
668
+ console.log(`${yellow(` ${typeLabel} 规则段落`)}`);
669
+ console.log(`${yellow(`═══════════════════════════════════════`)}`);
670
+ console.log(` 来源: ${section.sourceFile} (L${findLineNumber(section.content, section.sourceFile, target)})`);
671
+ console.log(` ${dim(section.header)}`);
672
+ console.log(` ${dim(section.content.slice(0, 250))}${section.content.length > 250 ? '…' : ''}`);
673
+
674
+ // Compare with psm default
675
+ let overlap = 0;
676
+ if (defaultContent) {
677
+ overlap = calcRuleOverlap(section.content, defaultContent);
678
+ if (overlap > 0.3) {
679
+ console.log(` ${yellow('⚠ 此规则与 psm 默认规则相似度较高')} (${Math.round(overlap * 100)}%)`);
680
+ }
681
+ }
682
+
683
+ // Build options
684
+ const options = [
685
+ `提取到 .agents/rules/${ruleFile},${section.sourceFile} 中替换为引用`,
686
+ `保留在 ${section.sourceFile} 原处,不提取`,
687
+ ];
688
+ if (overlap > 0.3) {
689
+ options.push(`使用 psm 默认规则替换(丢弃此段)`);
690
+ } else {
691
+ options.push(`同时保留两处(拷贝到规则文件 + 保留 ${section.sourceFile} 原内容)`);
692
+ }
693
+
694
+ const choice = await askChoice(
695
+ overlap > 0.3
696
+ ? `此规则与 psm 默认规则 ${Math.round(overlap * 100)}% 相似,如何处理?`
697
+ : `如何处理此${typeLabel}规则段落?`,
698
+ options,
699
+ );
700
+
701
+ if (choice === 0) {
702
+ // Extract + replace in the source file
703
+ extracted.push(section.content);
704
+ const refText = `\n> **${typeLabel}:** 详见 \`.agents/rules/${ruleFile}\`(按需加载)\n`;
705
+ if (section.sourceFile === 'AGENTS.md') {
706
+ agContent = agContent.replace(section.content, refText);
707
+ fs.writeFileSync(agentPath, agContent, 'utf-8');
708
+ } else {
709
+ let clContent = fs.readFileSync(claudePath, 'utf-8');
710
+ clContent = clContent.replace(section.content, refText);
711
+ fs.writeFileSync(claudePath, clContent, 'utf-8');
712
+ }
713
+ console.log(cyan(` → 已提取,${section.sourceFile} 中替换为引用`));
714
+ } else if (choice === 1) {
715
+ console.log(cyan(` → 保留在原处`));
716
+ } else if (choice === 2 && overlap > 0.3) {
717
+ // Use psm default instead — don't extract user version
718
+ console.log(cyan(` → 使用 psm 默认规则,丢弃此段`));
719
+ } else {
720
+ // Keep both
721
+ extracted.push(section.content);
722
+ console.log(cyan(` → 已复制到规则文件,${section.sourceFile} 保留原内容`));
723
+ }
724
+ }
725
+
726
+ return extracted;
727
+ }
728
+
729
+ function findLineNumber(content, sourceFile, target) {
730
+ const filePath = path.join(target, sourceFile);
731
+ if (!fs.existsSync(filePath)) return '?';
732
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
733
+ const firstLine = content.split('\n')[0];
734
+ for (let i = 0; i < lines.length; i++) {
735
+ if (lines[i] === firstLine) return i + 1;
736
+ }
737
+ return '?';
738
+ }
739
+
740
+ /**
741
+ * Confirm and inject skill tree entry into AGENTS.md.
742
+ */
743
+ async function confirmInjectLoadingChain(target, autoYes = false) {
744
+ const agentsPath = path.join(target, 'AGENTS.md');
745
+ if (!fs.existsSync(agentsPath)) return false;
746
+ let content = fs.readFileSync(agentsPath, 'utf-8');
747
+ if (/psm 技能树入口/.test(content)) return false; // already has it
748
+
749
+ if (!autoYes) {
750
+ console.log(`\n${yellow('═══ AGENTS.md 注入 ═══')}`);
751
+ console.log('需要在 AGENTS.md 末尾添加 psm 技能树入口引用,确保 AI Agent 加载技能树。');
752
+ const ok = await askYesNo('是否注入技能树入口引用?', true);
753
+ if (!ok) {
754
+ console.log(cyan(' → 跳过'));
755
+ return false;
756
+ }
757
+ }
758
+
759
+ const backupPath = path.join(target, '.agents', '.psm-backup');
760
+ if (!fs.existsSync(backupPath)) {
761
+ fs.mkdirSync(backupPath, { recursive: true });
762
+ }
763
+ fs.copyFileSync(agentsPath, path.join(backupPath, 'AGENTS.md'));
764
+
765
+ const injection = `\n---\n## psm 技能树入口\n\n读取 \`.agents/skills/INDEX.md\` 了解技能树和按需加载规则。\n`;
766
+ content += injection;
767
+ fs.writeFileSync(agentsPath, content, 'utf-8');
768
+ return true;
769
+ }
770
+
771
+ /**
772
+ * Confirm and inject @AGENTS.md into CLAUDE.md.
773
+ */
774
+ async function confirmInjectClaudeRef(target, autoYes = false) {
775
+ const claudePath = path.join(target, 'CLAUDE.md');
776
+ if (!fs.existsSync(claudePath)) return false;
777
+ let content = fs.readFileSync(claudePath, 'utf-8');
778
+ if (/@AGENTS\.md/.test(content)) return false; // already has it
779
+
780
+ if (!autoYes) {
781
+ console.log(`\n${yellow('═══ CLAUDE.md 注入 ═══')}`);
782
+ console.log('需要在 CLAUDE.md 开头添加 @AGENTS.md 引用,确保 Claude Code 能加载 AGENTS.md 中的规则。');
783
+ const ok = await askYesNo('是否添加 @AGENTS.md 引用?', true);
784
+ if (!ok) {
785
+ console.log(cyan(' → 跳过'));
786
+ return false;
787
+ }
788
+ }
789
+
790
+ const backupPath = path.join(target, '.agents', '.psm-backup');
791
+ if (!fs.existsSync(backupPath)) {
792
+ fs.mkdirSync(backupPath, { recursive: true });
793
+ }
794
+ fs.copyFileSync(claudePath, path.join(backupPath, 'CLAUDE.md'));
795
+
796
+ content = `@AGENTS.md\n\n${content}`;
797
+ fs.writeFileSync(claudePath, content, 'utf-8');
798
+ return true;
799
+ }
800
+
801
+ // ---- Install Plan ----
802
+
803
+ function buildInstallPlan(target) {
804
+ const projectType = detectProjectType(target);
805
+ const existingDocs = scanExistingAiDocs(target);
806
+ const existingSkillDirs = scanExistingSkillDirs(target);
807
+
808
+ const hasAgents = !!existingDocs.AGENTS;
809
+ const hasClaude = !!existingDocs.CLAUDE;
810
+ const hasCursorRules = existingDocs.cursorRules && existingDocs.cursorRules.length > 0;
811
+ const hasOtherSkills = existingSkillDirs.length > 0;
812
+
813
+ // Scan for conflicts
814
+ let versionConflict = false;
815
+ let standardsConflict = false;
816
+ let versionMatches = [];
817
+ let standardsMatches = [];
818
+
819
+ if (existingDocs.AGENTS) {
820
+ versionMatches = scanVersionManagement(existingDocs.AGENTS);
821
+ standardsMatches = scanCodeStandards(existingDocs.AGENTS);
822
+ if (versionMatches.length > 0) versionConflict = true;
823
+ if (standardsMatches.length > 0) standardsConflict = true;
824
+ }
825
+ if (existingDocs.CLAUDE) {
826
+ const cv = scanVersionManagement(existingDocs.CLAUDE);
827
+ const cs = scanCodeStandards(existingDocs.CLAUDE);
828
+ if (cv.length > 0) versionConflict = true;
829
+ if (cs.length > 0) standardsConflict = true;
830
+ versionMatches = versionMatches.concat(cv);
831
+ standardsMatches = standardsMatches.concat(cs);
832
+ }
833
+
834
+ const needsLoadingChain = existingDocs.AGENTS ? !hasLoadingChain(existingDocs.AGENTS) : false;
835
+ const needsClaudeRef = existingDocs.CLAUDE ? !hasAgentsRef(existingDocs.CLAUDE) : false;
836
+
837
+ return {
838
+ projectType,
839
+ hasAgents,
840
+ hasClaude,
841
+ hasCursorRules,
842
+ hasOtherSkills,
843
+ otherSkillDirs: existingSkillDirs,
844
+ versionConflict,
845
+ standardsConflict,
846
+ versionMatches,
847
+ standardsMatches,
848
+ needsLoadingChain,
849
+ needsClaudeRef,
850
+ };
851
+ }
852
+
853
+ function showInstallPlan(target, plan) {
854
+ const hasIssues = plan.versionConflict || plan.standardsConflict || plan.hasOtherSkills;
855
+
856
+ console.log(`\n${cyan('═══════════════════════════════════════')}`);
857
+ console.log(`${cyan(' psm Install Plan')}`);
858
+ console.log(`${cyan('═══════════════════════════════════════')}`);
859
+ console.log(`\n ${cyan('Target:')} ${target}`);
860
+ console.log(` ${cyan('Type:')} ${plan.projectType}`);
861
+ console.log();
862
+
863
+ // AI docs
864
+ console.log(` ${cyan('AI Documents')}`);
865
+ console.log(` AGENTS.md ${plan.hasAgents ? green('found') : yellow('not found')}`);
866
+ console.log(` CLAUDE.md ${plan.hasClaude ? green('found') : yellow('not found')}`);
867
+ if (plan.hasCursorRules) {
868
+ console.log(` .cursor/rules/ ${green(`${plan.cursorRules.length} rules found`)}`);
869
+ }
870
+ console.log();
871
+
872
+ // Conflicts
873
+ if (plan.versionConflict) {
874
+ console.log(` ${yellow('⚠ Version Management Conflict')}`);
875
+ for (const m of plan.versionMatches) {
876
+ console.log(` L${m.line}: ${dim(m.text)}`);
877
+ }
878
+ }
879
+ if (plan.standardsConflict) {
880
+ console.log(` ${yellow('⚠ Code Standards Conflict')}`);
881
+ for (const m of plan.standardsMatches) {
882
+ console.log(` L${m.line}: ${dim(m.text)}`);
883
+ }
884
+ }
885
+ if (!plan.versionConflict && !plan.standardsConflict) {
886
+ console.log(` ${green('No rule conflicts detected')}`);
887
+ }
888
+ console.log();
889
+
890
+ // Existing skill dirs
891
+ if (plan.hasOtherSkills) {
892
+ console.log(` ${yellow('Existing Skill Directories')}`);
893
+ for (const d of plan.otherSkillDirs) {
894
+ console.log(` ${d.path} (${d.skills.length} skills)`);
895
+ }
896
+ console.log();
897
+ }
898
+
899
+ // Actions
900
+ console.log(` ${cyan('Actions to perform:')}`);
901
+ console.log(` ${green('1')} Copy .agents/ → skills + rules`);
902
+ if (plan.needsLoadingChain) {
903
+ console.log(` ${green('2')} Add skill tree entry to AGENTS.md`);
904
+ }
905
+ if (plan.needsClaudeRef) {
906
+ console.log(` ${green('3')} Add @AGENTS.md reference to CLAUDE.md`);
907
+ }
908
+ if (plan.versionConflict) {
909
+ console.log(` ${yellow('?')} Extract version management rules → .agents/rules/`);
910
+ }
911
+ if (plan.standardsConflict) {
912
+ console.log(` ${yellow('?')} Extract code standards rules → .agents/rules/`);
913
+ }
914
+ console.log(` ${green('4')} Copy scripts/`);
915
+ console.log(` ${green('5')} Generate INDEX.md`);
916
+ console.log();
917
+
918
+ if (hasIssues) {
919
+ console.log(` ${yellow('Some items require your input during install.')}\n`);
920
+ } else {
921
+ console.log(` ${green('No conflicts — installation will proceed automatically.')}\n`);
922
+ }
923
+ }
924
+
925
+ // ---- Commands ----
926
+
927
+ function cmdHelp() {
928
+ console.log(`\
929
+ ${green('psm v' + VERSION)}
930
+
931
+ ${cyan('Usage:')}
932
+ npx psmgr install [-y] [--preview] [target]
933
+ npx psmgr check [target]
934
+ npx psmgr info [target]
935
+ npx psmgr list
936
+ npx psmgr registry
937
+ npx psmgr discover [target]
938
+ npx psmgr outdated
939
+ npx psmgr update
940
+ npx psmgr version / -v
941
+ npx psmgr help / -h
942
+
943
+ ${cyan('Install options:')}
944
+ -y, --yes Skip prompts, overwrite existing
945
+ --preview Show install plan only, do not install
946
+
947
+ ${cyan('Registry commands:')}
948
+ npx psmgr registry List all skill sources from registry
949
+ npx psmgr discover [target] Show skills matching this project's tech stack
950
+
951
+ ${cyan('Tool commands:')}
952
+ npx psmgr tool list [target] List tools & installation status
953
+ npx psmgr tool install <name> [target] Install a tool (CLI or MCP)
954
+ npx psmgr tool verify [target] Verify installed tool commands work
955
+ npx psmgr tool setup [target] Scan skills & install missing tools
956
+
957
+ ${cyan('Examples:')}
958
+ npx psmgr install Install into current directory
959
+ npx psmgr install ../my-app Install into ../my-app
960
+ npx psmgr install --preview Preview install plan
961
+ npx psmgr install -y Quiet install, overwrite existing
962
+ npx psmgr check Check current directory
963
+ npx psmgr info Show version + env + status
964
+ npx psmgr registry List available skill sources
965
+ npx psmgr discover Discover matching skills for this project
966
+ npx psmgr tool list List tools and their status
967
+ npx psmgr tool install codegraph Install codegraph (CLI or MCP)
968
+ npx psmgr tool setup Auto-detect and install missing tools
969
+ npx psmgr tool verify Verify installed tool commands
970
+ `);
971
+ }
972
+
973
+ function cmdVersion() {
974
+ console.log(`psm v${VERSION}`);
975
+ }
976
+
977
+ // ---- registry ----
978
+
979
+ function cmdRegistry() {
980
+ const registry = readRegistry();
981
+ if (!registry) {
982
+ die('skills-registry.json not found in package.');
983
+ }
984
+
985
+ console.log(`\n${green('PSM Skill Registry')}`);
986
+ console.log(` Version: ${registry.version}`);
987
+ console.log(` Sources: ${registry.sources.length}\n`);
988
+
989
+ for (const src of registry.sources) {
990
+ const tag = src.selfManaged ? '🔄 self-managed'
991
+ : src.filter === 'always' ? '✅ always'
992
+ : '⚠️ tech-stack';
993
+ console.log(` ${cyan(src.name)} ${dim(`(${tag})`)}`);
994
+ console.log(` ${src.description}`);
995
+ if (src.skills.length > 0) {
996
+ console.log(` Skills: ${src.skills.join(', ')}`);
997
+ }
998
+ if (src.filter === 'tech-stack' && src.match) {
999
+ const deps = src.match.dependencies || [];
1000
+ const files = src.match.files || [];
1001
+ console.log(` Matches: ${[...deps, ...files].join(', ')}`);
1002
+ }
1003
+ console.log();
1004
+ }
1005
+ }
1006
+
1007
+ // ---- discover ----
1008
+
1009
+ function cmdDiscover(targetDir) {
1010
+ const target = path.resolve(targetDir || process.cwd());
1011
+ const registry = readRegistry();
1012
+ if (!registry) {
1013
+ die('skills-registry.json not found in package.');
1014
+ }
1015
+
1016
+ // Detect tech stack
1017
+ const projectType = detectProjectType(target);
1018
+ const pkgJsonPath = path.join(target, 'package.json');
1019
+ let dependencies = [];
1020
+ if (fs.existsSync(pkgJsonPath)) {
1021
+ try {
1022
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
1023
+ dependencies = Object.keys(pkg.dependencies || {}).concat(Object.keys(pkg.devDependencies || {}));
1024
+ } catch {}
1025
+ }
1026
+
1027
+ // Read user config
1028
+ const userConfig = readUserConfig(target);
1029
+
1030
+ console.log(`\n${green('PSM Skill Discovery')}`);
1031
+ console.log(` Project: ${target}`);
1032
+ console.log(` Type: ${projectType}`);
1033
+ console.log(` Dependencies: ${dependencies.length > 0 ? dependencies.slice(0, 10).join(', ') + (dependencies.length > 10 ? '...' : '') : '(none detected)'}`);
1034
+ console.log();
1035
+
1036
+ // Always-install sources
1037
+ const always = registry.sources.filter(s => s.filter === 'always' && !s.selfManaged);
1038
+ const techStack = registry.sources.filter(s => s.filter === 'tech-stack');
1039
+ const selfManaged = registry.sources.filter(s => s.selfManaged);
1040
+
1041
+ console.log(`${green('✅ Always recommended')}`);
1042
+ for (const src of always) {
1043
+ console.log(` ${cyan(src.name)} — ${src.description}`);
1044
+ console.log(` Skills: ${src.skills.join(', ')}`);
1045
+ }
1046
+ console.log();
1047
+
1048
+ // Tech-stack matched
1049
+ const matched = techStack.filter(src => {
1050
+ if (!src.match || !src.match.dependencies) return dependencies.length > 0;
1051
+ return src.match.dependencies.some(d => dependencies.includes(d));
1052
+ });
1053
+ if (matched.length > 0) {
1054
+ console.log(`${yellow('⚠️ Tech-stack matched (recommended)')}`);
1055
+ for (const src of matched) {
1056
+ console.log(` ${cyan(src.name)} — ${src.description}`);
1057
+ console.log(` Skills: ${src.skills.join(', ')}`);
1058
+ }
1059
+ console.log();
1060
+ }
1061
+
1062
+ // Self-managed
1063
+ console.log(`${dim('🔄 Self-managed (install via their own CLI)')}`);
1064
+ for (const src of selfManaged) {
1065
+ console.log(` ${dim(src.name)} — ${src.description}`);
1066
+ }
1067
+ console.log();
1068
+
1069
+ // User custom sources
1070
+ if (userConfig.customSources.length > 0) {
1071
+ console.log(`${cyan('📦 Custom sources (from .agents/skills-config.json)')}`);
1072
+ for (const src of userConfig.customSources) {
1073
+ console.log(` ${src.name} — ${src.description || '(no description)'}`);
1074
+ }
1075
+ console.log();
1076
+ }
1077
+
1078
+ if (!always.length && !matched.length && !selfManaged.length && !userConfig.customSources.length) {
1079
+ console.log(' No skills matched your project.\n');
1080
+ }
1081
+ }
1082
+
1083
+ // ---- Tool Management ----
1084
+
1085
+ /**
1086
+ * Read tool definitions from the skills registry.
1087
+ */
1088
+ function readToolRegistry() {
1089
+ const registry = readRegistry();
1090
+ return registry?.tools?.items || [];
1091
+ }
1092
+
1093
+ /**
1094
+ * Read the tool index from a target project.
1095
+ * Returns a map of tool name → tool info.
1096
+ */
1097
+ function readToolIndex(target) {
1098
+ const indexPath = path.join(target || '.', '.agents', 'tools.json');
1099
+ if (!fs.existsSync(indexPath)) return {};
1100
+ try {
1101
+ return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
1102
+ } catch {
1103
+ return {};
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * Write the tool index to a target project.
1109
+ * Index shape per tool:
1110
+ * { installed, mode, version, path, commands: [{cmd, verified, lastOk}], verifiedAt }
1111
+ */
1112
+ function writeToolIndex(target, index) {
1113
+ const dir = path.join(target, '.agents');
1114
+ fs.mkdirSync(dir, { recursive: true });
1115
+ fs.writeFileSync(path.join(dir, 'tools.json'), JSON.stringify(index, null, 2), 'utf-8');
1116
+ }
1117
+
1118
+ /**
1119
+ * Check if a tool is currently available on the system PATH.
1120
+ */
1121
+ function checkToolAvailable(name) {
1122
+ try {
1123
+ const result = execSync(`${process.platform === 'win32' ? 'where' : 'which'} ${name}`, {
1124
+ encoding: 'utf-8',
1125
+ timeout: 3000,
1126
+ stdio: ['ignore', 'pipe', 'pipe'],
1127
+ });
1128
+ return result.trim().length > 0;
1129
+ } catch {
1130
+ return false;
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Get the full path of a tool binary.
1136
+ */
1137
+ function getToolPath(name) {
1138
+ try {
1139
+ const result = execSync(`${process.platform === 'win32' ? 'where' : 'which'} ${name}`, {
1140
+ encoding: 'utf-8',
1141
+ timeout: 3000,
1142
+ stdio: ['ignore', 'pipe', 'pipe'],
1143
+ });
1144
+ return result.trim().split('\n')[0] || null;
1145
+ } catch {
1146
+ return null;
1147
+ }
1148
+ }
1149
+
1150
+ /**
1151
+ * Get the version of an installed tool.
1152
+ */
1153
+ function getToolVersion(name) {
1154
+ try {
1155
+ const result = execSync(`${name} --version`, {
1156
+ encoding: 'utf-8',
1157
+ timeout: 3000,
1158
+ stdio: ['ignore', 'pipe', 'pipe'],
1159
+ });
1160
+ return result.trim().split('\n')[0];
1161
+ } catch {
1162
+ return 'unknown';
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Test whether a specific subcommand works.
1168
+ * Returns { ok, output }.
1169
+ */
1170
+ function testSubcommand(cmd) {
1171
+ try {
1172
+ const result = execSync(`${cmd} --help`, {
1173
+ encoding: 'utf-8',
1174
+ timeout: 5000,
1175
+ stdio: ['ignore', 'pipe', 'pipe'],
1176
+ });
1177
+ return { ok: true, output: result.trim().split('\n')[0] };
1178
+ } catch (e) {
1179
+ // Some tools don't have --help on every subcommand; try without args
1180
+ try {
1181
+ const result = execSync(`${cmd}`, {
1182
+ encoding: 'utf-8',
1183
+ timeout: 5000,
1184
+ stdio: ['ignore', 'pipe', 'pipe'],
1185
+ });
1186
+ return { ok: true, output: result.trim().split('\n')[0] };
1187
+ } catch {
1188
+ return { ok: false, output: null };
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ /**
1194
+ * Verify all subcommands for a tool and return the verified list.
1195
+ */
1196
+ function verifyToolCommands(toolDef) {
1197
+ if (!toolDef.commands || toolDef.commands.length === 0) return [];
1198
+ console.log(dim(` 验证命令可用性...`));
1199
+ return toolDef.commands.map(c => {
1200
+ const { ok } = testSubcommand(c.cmd);
1201
+ const status = ok ? green('✓') : yellow('⚠');
1202
+ console.log(` ${status} ${c.cmd} — ${c.description}${ok ? '' : dim(' (不可用)')}`);
1203
+ return { cmd: c.cmd, description: c.description, verified: ok };
1204
+ });
1205
+ }
1206
+
1207
+ /**
1208
+ * Find the IDE's MCP config path by detecting common IDEs.
1209
+ */
1210
+ function getMcpConfigPath() {
1211
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1212
+ const candidates = [
1213
+ // Windows
1214
+ { path: path.join(process.env.APPDATA || '', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cli', 'mcp.json'), name: 'Roo Code' },
1215
+ { path: path.join(home, '.cursor', 'mcp.json'), name: 'Cursor' },
1216
+ { path: path.join(home, '.windsurf', 'mcp.json'), name: 'Windsurf' },
1217
+ { path: path.join(home, '.codex', 'mcp.json'), name: 'Codex' },
1218
+ { path: path.join(home, '.claude', 'mcp.json'), name: 'Claude Code' },
1219
+ ];
1220
+ for (const c of candidates) {
1221
+ if (fs.existsSync(c.path)) return c;
1222
+ }
1223
+ return null;
1224
+ }
1225
+
1226
+ /**
1227
+ * Write MCP server config to the IDE's MCP config file.
1228
+ */
1229
+ function writeMcpConfig(toolName, mcpConfig) {
1230
+ const ide = getMcpConfigPath();
1231
+ if (!ide) {
1232
+ console.log(yellow(` 未检测到支持的 IDE。请手动配置 MCP。`));
1233
+ return false;
1234
+ }
1235
+
1236
+ try {
1237
+ let config = { mcpServers: {} };
1238
+ if (fs.existsSync(ide.path)) {
1239
+ config = JSON.parse(fs.readFileSync(ide.path, 'utf-8'));
1240
+ if (!config.mcpServers) config.mcpServers = {};
1241
+ }
1242
+
1243
+ // Add or update the tool's MCP config
1244
+ config.mcpServers[toolName] = {
1245
+ command: mcpConfig.command,
1246
+ args: mcpConfig.args || [],
1247
+ };
1248
+
1249
+ fs.writeFileSync(ide.path, JSON.stringify(config, null, 2), 'utf-8');
1250
+ console.log(green(` → MCP 配置已写入 ${ide.path}`));
1251
+ return true;
1252
+ } catch (e) {
1253
+ console.log(yellow(` ⚠ 写入 MCP 配置失败: ${e.message}`));
1254
+ return false;
1255
+ }
1256
+ }
1257
+
1258
+ // ---- cmd: tool install ----
1259
+
1260
+ async function cmdToolInstall(toolName, targetDir) {
1261
+ const target = path.resolve(targetDir || process.cwd());
1262
+ const tools = readToolRegistry();
1263
+ const tool = tools.find(t => t.name === toolName);
1264
+
1265
+ if (!tool) {
1266
+ die(`未知工具: ${toolName}。可用工具: ${tools.map(t => t.name).join(', ')}`);
1267
+ }
1268
+
1269
+ console.log(`\n${green(`安装工具: ${tool.name}`)}`);
1270
+ console.log(` ${tool.description}`);
1271
+ console.log(` ${dim(tool.homepage)}\n`);
1272
+
1273
+ // Check if already installed
1274
+ if (checkToolAvailable(toolName)) {
1275
+ const ver = getToolVersion(toolName);
1276
+ const toolPath = getToolPath(toolName);
1277
+ console.log(green(`${toolName} 已安装 (${ver})`));
1278
+ console.log(dim(` 路径: ${toolPath}`));
1279
+ // Re-verify commands
1280
+ const cmds = verifyToolCommands(tool);
1281
+ const index = readToolIndex(target);
1282
+ index[toolName] = {
1283
+ installed: true, mode: 'cli', version: ver,
1284
+ path: toolPath, commands: cmds, verifiedAt: new Date().toISOString()
1285
+ };
1286
+ writeToolIndex(target, index);
1287
+ return;
1288
+ }
1289
+
1290
+ // Ask: CLI or MCP?
1291
+ const options = [];
1292
+ if (tool.cli) options.push(`CLI — ${tool.cli.description}`);
1293
+ if (tool.mcp) options.push(`MCP — ${tool.mcp.description}`);
1294
+ options.push('取消');
1295
+
1296
+ const choice = await askChoice(`选择 ${tool.name} 的安装方式`, options);
1297
+
1298
+ if (choice === options.length - 1) {
1299
+ console.log(cyan(' → 取消安装'));
1300
+ return;
1301
+ }
1302
+
1303
+ const isCli = choice === 0;
1304
+
1305
+ if (isCli && tool.cli) {
1306
+ console.log(`\n${cyan(`安装 CLI: ${tool.cli.commands[0]}`)}`);
1307
+ for (const cmd of tool.cli.commands) {
1308
+ try {
1309
+ console.log(dim(`$ ${cmd}`));
1310
+ execSync(cmd, { stdio: 'inherit', timeout: 120000 });
1311
+ } catch {
1312
+ console.log(yellow(` ⚠ 命令执行失败: ${cmd}`));
1313
+ if (!await askYesNo('继续尝试下一个安装方式?', false)) {
1314
+ return;
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ // Post-install setup
1320
+ if (tool.cli.postInstall && checkToolAvailable(toolName)) {
1321
+ console.log(`\n${cyan('运行安装后配置...')}`);
1322
+ try {
1323
+ execSync(tool.cli.postInstall, { stdio: 'inherit', timeout: 60000 });
1324
+ } catch {
1325
+ console.log(yellow(` ⚠ 安装后配置失败: ${tool.cli.postInstall}`));
1326
+ }
1327
+ }
1328
+
1329
+ // Verify: check path + each subcommand
1330
+ if (checkToolAvailable(toolName)) {
1331
+ const ver = getToolVersion(toolName);
1332
+ const toolPath = getToolPath(toolName);
1333
+ console.log(green(`\n${tool.name} CLI 安装成功 (${ver})`));
1334
+ const cmds = verifyToolCommands(tool);
1335
+ const index = readToolIndex(target);
1336
+ index[toolName] = {
1337
+ installed: true, mode: 'cli', version: ver,
1338
+ path: toolPath, commands: cmds, verifiedAt: new Date().toISOString()
1339
+ };
1340
+ writeToolIndex(target, index);
1341
+ console.log(green(` 命令索引已写入 .agents/tools.json`));
1342
+ } else {
1343
+ console.log(yellow(`\n⚠ ${tool.name} 安装可能未完成,请检查后重试。`));
1344
+ }
1345
+ } else if (!isCli && tool.mcp) {
1346
+ console.log(`\n${cyan('配置 MCP 服务器...')}`);
1347
+
1348
+ // Try auto-setup first
1349
+ if (tool.mcp.autoSetup) {
1350
+ try {
1351
+ console.log(dim(`$ ${tool.cli?.postInstall || 'codegraph install'}`));
1352
+ execSync(tool.cli?.postInstall || 'codegraph install', { stdio: 'inherit', timeout: 60000 });
1353
+ } catch {
1354
+ console.log(yellow(' 自动配置失败,尝试手动配置。'));
1355
+ }
1356
+ }
1357
+
1358
+ // Write MCP config to IDE
1359
+ let mcpWritten = false;
1360
+ if (tool.mcp.config) {
1361
+ console.log(`\n${cyan('写入 MCP 配置...')}`);
1362
+ mcpWritten = writeMcpConfig(toolName, tool.mcp.config);
1363
+
1364
+ if (!mcpWritten) {
1365
+ console.log(`\n${cyan('手动 MCP 配置:')}`);
1366
+ console.log(` ${JSON.stringify(tool.mcp.config, null, 2)}`);
1367
+ console.log(`\n${dim('请将以上配置添加到你的 IDE 的 MCP 配置文件中。')}`);
1368
+ console.log(dim('Cursor: ~/.cursor/mcp.json'));
1369
+ console.log(dim('Windsurf: ~/.windsurf/mcp.json'));
1370
+ console.log(dim('Claude Code: claude mcp add'));
1371
+ }
1372
+ }
1373
+
1374
+ const index = readToolIndex(target);
1375
+ index[toolName] = {
1376
+ installed: true, mode: 'mcp',
1377
+ mcpConfig: tool.mcp.config || null,
1378
+ mcpWrittenTo: mcpWritten ? getMcpConfigPath()?.path : null,
1379
+ commands: (tool.commands || []).map(c => ({ cmd: c.cmd, description: c.description, verified: false })),
1380
+ updatedAt: new Date().toISOString()
1381
+ };
1382
+ writeToolIndex(target, index);
1383
+ console.log(green(`\n${tool.name} MCP 配置已记录到 .agents/tools.json`));
1384
+ }
1385
+ }
1386
+
1387
+ // ---- cmd: tool list ----
1388
+
1389
+ function cmdToolList(targetDir) {
1390
+ const target = path.resolve(targetDir || process.cwd());
1391
+ const tools = readToolRegistry();
1392
+ const index = readToolIndex(target);
1393
+
1394
+ if (tools.length === 0) {
1395
+ console.log(yellow('注册中心中未定义工具。'));
1396
+ return;
1397
+ }
1398
+
1399
+ console.log(`\n${green('工具清单')}\n`);
1400
+ for (const t of tools) {
1401
+ const available = checkToolAvailable(t.name);
1402
+ const recorded = index[t.name];
1403
+
1404
+ // Status icon
1405
+ let status;
1406
+ if (available) {
1407
+ const cmdsOk = recorded?.commands?.every(c => c.verified === false) === false;
1408
+ status = cmdsOk ? green('✓ 已安装 (命令可用)') : green('✓ 已安装');
1409
+ } else if (recorded) {
1410
+ status = yellow(`已记录 (${recorded.mode})`);
1411
+ } else {
1412
+ status = dim('未安装');
1413
+ }
1414
+
1415
+ const ver = available ? ` (${getToolVersion(t.name)})` : '';
1416
+ console.log(` ${cyan(t.name)} ${status}${ver}`);
1417
+
1418
+ if (available || recorded) {
1419
+ const mode = recorded?.mode || 'cli';
1420
+ const toolPath = recorded?.path || (available ? getToolPath(t.name) : '—');
1421
+ console.log(` 方式: ${mode} 路径: ${toolPath}`);
1422
+ }
1423
+
1424
+ // Show recorded commands
1425
+ if (recorded?.commands?.length) {
1426
+ for (const c of recorded.commands) {
1427
+ const ok = c.verified ? green('✓') : dim('?');
1428
+ console.log(` ${ok} ${c.cmd} — ${c.description || ''}`);
1429
+ }
1430
+ } else if (t.commands?.length) {
1431
+ // Show default commands from registry (not yet verified)
1432
+ for (const c of t.commands) {
1433
+ console.log(` ${dim('·')} ${c.cmd} — ${c.description}`);
1434
+ }
1435
+ }
1436
+ console.log();
1437
+ }
1438
+ }
1439
+
1440
+ // ---- cmd: tool setup ----
1441
+
1442
+ async function cmdToolSetup(targetDir) {
1443
+ const target = path.resolve(targetDir || process.cwd());
1444
+ const tools = readToolRegistry();
1445
+ const index = readToolIndex(target);
1446
+
1447
+ // Scan skills for requires_tools
1448
+ const skillsDir = path.join(target, '.agents', 'skills');
1449
+ const requiredTools = new Set();
1450
+
1451
+ // Add tools from registry self-managed sources (they depend on their own CLI)
1452
+ const registry = readRegistry();
1453
+ if (registry) {
1454
+ for (const src of registry.sources) {
1455
+ if (src.selfManaged) {
1456
+ requiredTools.add(src.name);
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ // Scan installed skills for requires_tools in frontmatter
1462
+ if (fs.existsSync(skillsDir)) {
1463
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
1464
+ if (!entry.isDirectory()) continue;
1465
+ const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
1466
+ if (!fs.existsSync(skillMd)) continue;
1467
+ const content = fs.readFileSync(skillMd, 'utf-8');
1468
+ const match = content.match(/^requires_tools:\s*$/m);
1469
+ if (match) {
1470
+ const lines = content.slice(match.index).split('\n');
1471
+ for (let i = 1; i < lines.length; i++) {
1472
+ const toolMatch = lines[i].match(/^\s*-\s*(.+)/);
1473
+ if (toolMatch) requiredTools.add(toolMatch[1].trim());
1474
+ else break;
1475
+ }
1476
+ }
1477
+ }
1478
+ }
1479
+
1480
+ if (requiredTools.size === 0) {
1481
+ console.log(green('未检测到需要安装的工具依赖。'));
1482
+ return;
1483
+ }
1484
+
1485
+ console.log(`\n${cyan('检测到以下工具依赖:')}`);
1486
+ for (const t of requiredTools) {
1487
+ const available = checkToolAvailable(t);
1488
+ const recorded = index[t];
1489
+ if (!available && !recorded) {
1490
+ console.log(` ${yellow(t)} — 未安装`);
1491
+ } else {
1492
+ console.log(` ${green(t)} — ${available ? '已安装' : `已记录 (${recorded.mode})`}`);
1493
+ }
1494
+ }
1495
+
1496
+ for (const t of requiredTools) {
1497
+ if (!checkToolAvailable(t) && !index[t]) {
1498
+ console.log();
1499
+ const ok = await askYesNo(`安装 ${t}?`, true);
1500
+ if (ok) {
1501
+ await cmdToolInstall(t, target);
1502
+ } else {
1503
+ console.log(cyan(` → 跳过 ${t}`));
1504
+ }
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ // ---- cmd: tool verify ----
1510
+
1511
+ function cmdToolVerify(targetDir) {
1512
+ const target = path.resolve(targetDir || process.cwd());
1513
+ const tools = readToolRegistry();
1514
+ const index = readToolIndex(target);
1515
+
1516
+ if (tools.length === 0) {
1517
+ console.log(yellow('注册中心中未定义工具。'));
1518
+ return;
1519
+ }
1520
+
1521
+ console.log(`\n${green('验证工具命令可用性')}\n`);
1522
+ let allOk = true;
1523
+
1524
+ for (const t of tools) {
1525
+ const available = checkToolAvailable(t.name);
1526
+ if (!available) {
1527
+ console.log(` ${yellow(t.name)} ${dim('未安装,跳过验证')}`);
1528
+ continue;
1529
+ }
1530
+
1531
+ const ver = getToolVersion(t.name);
1532
+ const toolPath = getToolPath(t.name);
1533
+ console.log(` ${cyan(t.name)} ${dim(`(${ver})`)}`);
1534
+ console.log(` 路径: ${toolPath}`);
1535
+
1536
+ if (t.commands && t.commands.length > 0) {
1537
+ const verified = verifyToolCommands(t);
1538
+ // Update index
1539
+ index[t.name] = {
1540
+ ...(index[t.name] || {}),
1541
+ installed: true, mode: index[t.name]?.mode || 'cli',
1542
+ version: ver, path: toolPath,
1543
+ commands: verified, verifiedAt: new Date().toISOString()
1544
+ };
1545
+ const hasFailures = verified.some(c => !c.verified);
1546
+ if (hasFailures) allOk = false;
1547
+ } else {
1548
+ console.log(` ${dim('无可验证的子命令')}`);
1549
+ }
1550
+ }
1551
+
1552
+ writeToolIndex(target, index);
1553
+ console.log(allOk ? green('\n✓ 所有工具命令可用') : yellow('\n⚠ 部分子命令不可用,请检查工具安装'));
1554
+ }
1555
+
1556
+ function cmdList() {
1557
+ const skillsDir = path.join(PKG_DIR, '.agents', 'skills');
1558
+
1559
+ if (!fs.existsSync(skillsDir)) {
1560
+ die('No skills found in package — package may be corrupted.');
1561
+ }
1562
+
1563
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
1564
+ const skills = entries
1565
+ .filter((e) => e.isDirectory())
1566
+ .map((e) => {
1567
+ const skillMd = path.join(skillsDir, e.name, 'SKILL.md');
1568
+ if (!fs.existsSync(skillMd)) {
1569
+ return { name: e.name, desc: dim('(missing SKILL.md)') };
1570
+ }
1571
+ const content = fs.readFileSync(skillMd, 'utf-8');
1572
+ const match = content.match(/^description:\s*(.+)/m);
1573
+ const desc = match ? match[1].trim() : dim('(no description)');
1574
+ return { name: e.name, desc };
1575
+ });
1576
+
1577
+ console.log(`\n${green(`Available skills (${skills.length})`)}\n`);
1578
+ for (const s of skills) {
1579
+ console.log(` ${cyan(s.name)}`);
1580
+ console.log(` ${s.desc}\n`);
1581
+ }
1582
+
1583
+ // Rules
1584
+ const rulesDir = path.join(PKG_DIR, '.agents', 'rules');
1585
+ if (fs.existsSync(rulesDir)) {
1586
+ const rules = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md'));
1587
+ console.log(`${cyan(`Rules (${rules.length})`)}`);
1588
+ for (const r of rules) {
1589
+ const loadType = r === 'project-rules.md' ? 'always-load'
1590
+ : r === 'code-standards-rules.md' || r === 'version-management-rules.md' ? 'on-demand (triggered)'
1591
+ : 'on-demand';
1592
+ console.log(` ${r.padEnd(35)} ${loadType}`);
1593
+ }
1594
+ console.log();
1595
+ }
1596
+
1597
+ // Scripts
1598
+ const scriptsDir = path.join(PKG_DIR, 'scripts');
1599
+ if (fs.existsSync(scriptsDir)) {
1600
+ const scripts = fs.readdirSync(scriptsDir);
1601
+ if (scripts.length > 0) {
1602
+ console.log(`${cyan('Scripts')}`);
1603
+ for (const s of scripts) {
1604
+ const compat = s.endsWith('.js') ? ' (cross-platform)' : s.endsWith('.sh') ? ' (bash)' : '';
1605
+ console.log(` ${s}${compat}`);
1606
+ }
1607
+ console.log();
1608
+ }
1609
+ }
1610
+ }
1611
+
1612
+ // ---- install ----
1613
+
1614
+ async function cmdInstall(targetDir, opts = {}) {
1615
+ const target = path.resolve(targetDir || process.cwd());
1616
+ const autoYes = opts.yes; // --yes: auto-answer "replace" but NEVER delete without asking
1617
+
1618
+ // Validate target
1619
+ if (!fs.existsSync(target)) {
1620
+ die(`Target directory does not exist: ${target}`, EXIT.ERR_NO_TARGET);
1621
+ }
1622
+ if (!fs.statSync(target).isDirectory()) {
1623
+ die(`Target is not a directory: ${target}`, EXIT.ERR_NO_TARGET);
1624
+ }
1625
+
1626
+ // Build and show install plan
1627
+ const plan = buildInstallPlan(target);
1628
+ showInstallPlan(target, plan);
1629
+
1630
+ // --preview: stop here
1631
+ if (opts.preview) {
1632
+ console.log(`${green('Preview mode — no changes made.')}\n`);
1633
+ return;
1634
+ }
1635
+
1636
+ // ── Per-section extraction (one section at a time) ──
1637
+
1638
+ let extractedVersion = [];
1639
+ if (plan.versionConflict) {
1640
+ console.log(`\n${yellow('═══════════════════════════════════════')}`);
1641
+ console.log(`${yellow(' 版本管理规则 — 逐段处理')}`);
1642
+ console.log(`${yellow('═══════════════════════════════════════')}`);
1643
+ extractedVersion = await extractSectionsInteractive(target, 'version', autoYes);
1644
+ }
1645
+
1646
+ let extractedStandards = [];
1647
+ if (plan.standardsConflict) {
1648
+ console.log(`\n${yellow('═══════════════════════════════════════')}`);
1649
+ console.log(`${yellow(' 代码规范规则 — 逐段处理')}`);
1650
+ console.log(`${yellow('═══════════════════════════════════════')}`);
1651
+ extractedStandards = await extractSectionsInteractive(target, 'standards', autoYes);
1652
+ }
1653
+
1654
+ // ── Existing skill dirs ──
1655
+ if (plan.hasOtherSkills && !autoYes) {
1656
+ console.log(`\n${yellow('═══════════════════════════════════════')}`);
1657
+ console.log(`${yellow(' 已有技能目录')}`);
1658
+ console.log(`${yellow('═══════════════════════════════════════')}`);
1659
+ for (const d of plan.otherSkillDirs) {
1660
+ console.log(` 检测到 ${d.path}(${d.skills.length} 个技能)`);
1661
+ if (d.skills.length > 0) {
1662
+ const action = await askChoice(
1663
+ `如何处理 ${d.path}?`,
1664
+ [
1665
+ '保持不动,psm 不管理此目录',
1666
+ '迁移到 .agents/skills/(保留原目录)',
1667
+ '迁移到 .agents/skills/(迁移后删除原目录)',
1668
+ ],
1669
+ );
1670
+ if (action === 0) {
1671
+ console.log(cyan(` → ${d.path} 保持不动`));
1672
+ } else if (action === 1) {
1673
+ console.log(cyan(` → ${d.path} 迁移(保留原目录)`));
1674
+ } else {
1675
+ console.log(cyan(` → ${d.path} 迁移(完成后删除)`));
1676
+ }
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ // ── AGENTS.md / CLAUDE.md injection (confirm first) ──
1682
+ const chainInjected = await confirmInjectLoadingChain(target, autoYes);
1683
+ const claudeRefInjected = await confirmInjectClaudeRef(target, autoYes);
1684
+
1685
+ // ── Copy .agents/ with per-file conflict resolution ──
1686
+ console.log(`\n${cyan('═══ 安装技能与规则 ═══')}\n`);
1687
+
1688
+ if (fs.existsSync(AGENTS_SRC)) {
1689
+ const dest = path.join(target, '.agents');
1690
+ const result = await safeCopyRecursive(AGENTS_SRC, dest, '.agents', autoYes);
1691
+ console.log(green(`.agents/ 复制完成:${result.copied} 新增, ${result.replaced} 替换, ${result.kept} 跳过`));
1692
+
1693
+ // ── Generate tech-stack-aware project-rules.md ──
1694
+ const projectRulesContent = generateProjectRules(target);
1695
+ if (projectRulesContent) {
1696
+ const prPath = path.join(target, '.agents', 'rules', 'project-rules.md');
1697
+ fs.writeFileSync(prPath, projectRulesContent, 'utf-8');
1698
+ console.log(green(' project-rules.md 已根据技术栈生成'));
1699
+ }
1700
+ }
1701
+
1702
+ // ── Save extracted rules (version + standards) ──
1703
+ if (extractedVersion.length > 0) {
1704
+ const vrmPath = path.join(target, '.agents', 'rules', 'version-management-rules.md');
1705
+ if (fs.existsSync(vrmPath)) {
1706
+ let vrmContent = fs.readFileSync(vrmPath, 'utf-8');
1707
+ const marker = '<!-- psm:project-custom -->';
1708
+ const markerIdx = vrmContent.indexOf(marker);
1709
+ if (markerIdx !== -1) {
1710
+ const before = vrmContent.slice(0, markerIdx + marker.length);
1711
+ const after = vrmContent.slice(markerIdx + marker.length);
1712
+ vrmContent = `${before}\n\n### 从项目 AGENTS.md 提取的规则\n\n${extractedVersion.join('\n\n---\n\n')}\n${after}`;
1713
+ fs.writeFileSync(vrmPath, vrmContent, 'utf-8');
1714
+ console.log(green(`版本管理规则已保存到 version-management-rules.md(${extractedVersion.length} 段)`));
1715
+ }
1716
+ }
1717
+ }
1718
+
1719
+ if (extractedStandards.length > 0) {
1720
+ const csPath = path.join(target, '.agents', 'rules', 'code-standards-rules.md');
1721
+ if (fs.existsSync(csPath)) {
1722
+ let csContent = fs.readFileSync(csPath, 'utf-8');
1723
+ const marker = '<!-- psm:project-custom -->';
1724
+ const markerIdx = csContent.indexOf(marker);
1725
+ if (markerIdx !== -1) {
1726
+ const before = csContent.slice(0, markerIdx + marker.length);
1727
+ const after = csContent.slice(markerIdx + marker.length);
1728
+ csContent = `${before}\n\n### 从项目 AGENTS.md 提取的规则\n\n${extractedStandards.join('\n\n---\n\n')}\n${after}`;
1729
+ fs.writeFileSync(csPath, csContent, 'utf-8');
1730
+ console.log(green(`代码规范规则已保存到 code-standards-rules.md(${extractedStandards.length} 段)`));
1731
+ }
1732
+ }
1733
+ }
1734
+
1735
+ // ── Copy scripts/ ──
1736
+ if (fs.existsSync(SCRIPTS_SRC)) {
1737
+ const dest = path.join(target, 'scripts');
1738
+ const result = await safeCopyRecursive(SCRIPTS_SRC, dest, 'scripts', autoYes);
1739
+ console.log(green(`scripts/ 复制完成:${result.copied} 新增, ${result.replaced} 替换, ${result.kept} 跳过`));
1740
+ }
1741
+
1742
+ // ── Final confirmation before writing INDEX.md ──
1743
+ if (!autoYes) {
1744
+ console.log();
1745
+ const proceed = await askYesNo('确认完成安装?', true);
1746
+ if (!proceed) {
1747
+ console.log(yellow('Installation cancelled. Backup files are in .agents/.psm-backup/'));
1748
+ return;
1749
+ }
1750
+ }
1751
+
1752
+ // ── Generate INDEX.md ──
1753
+ const indexContent = generateIndexMd(target);
1754
+ const indexDest = path.join(target, '.agents', 'skills', 'INDEX.md');
1755
+ fs.writeFileSync(indexDest, indexContent, 'utf-8');
1756
+ console.log(green('INDEX.md 已生成'));
1757
+
1758
+ // ── Check tool dependencies ──
1759
+ console.log(`\n${cyan('═══ 检查工具依赖 ═══')}\n`);
1760
+ const registry = readRegistry();
1761
+ if (registry?.tools?.items) {
1762
+ const index = readToolIndex(target);
1763
+ for (const t of registry.tools.items) {
1764
+ const available = checkToolAvailable(t.name);
1765
+ const recorded = index[t.name];
1766
+ if (!available && !recorded) {
1767
+ console.log(` ${yellow(t.name)} — ${t.description}`);
1768
+ if (!autoYes) {
1769
+ const ok = await askYesNo(`安装 ${t.name}?`, true);
1770
+ if (ok) {
1771
+ await cmdToolInstall(t.name, target);
1772
+ } else {
1773
+ console.log(cyan(` → 跳过 ${t.name}`));
1774
+ }
1775
+ }
1776
+ } else {
1777
+ console.log(` ${green(t.name)} ${available ? '✓ 已安装' : '✓ 已记录'}`);
1778
+ }
1779
+ }
1780
+ }
1781
+ console.log();
1782
+
1783
+ // ── Done ──
1784
+ const hasNodeBootstrap = fs.existsSync(path.join(target, 'scripts', 'bootstrap.js'));
1785
+ console.log(`\n${green('Installation complete!')}`);
1786
+ if (chainInjected) console.log(green(' AGENTS.md 技能树入口已注入'));
1787
+ if (claudeRefInjected) console.log(green(' CLAUDE.md @AGENTS.md 引用已注入'));
1788
+
1789
+ console.log(cyan('Run the bootstrap check:'));
1790
+ if (hasNodeBootstrap) {
1791
+ console.log(` node scripts/bootstrap.js`);
1792
+ } else {
1793
+ console.log(` cd ${target} && bash scripts/bootstrap.sh`);
1794
+ }
1795
+ console.log(cyan('Then in your IDE, say: 「安装技能和规则」'));
1796
+ console.log();
1797
+ }
1798
+
1799
+ // ---- check ----
1800
+
1801
+ function cmdCheck(targetDir) {
1802
+ const target = path.resolve(targetDir || process.cwd());
1803
+
1804
+ // Try Node.js bootstrap first (cross-platform)
1805
+ const bootstrapJs = path.join(target, 'scripts', 'bootstrap.js');
1806
+ const bootstrapSh = path.join(target, 'scripts', 'bootstrap.sh');
1807
+
1808
+ if (fs.existsSync(bootstrapJs)) {
1809
+ try {
1810
+ execSync(`node "${bootstrapJs}"`, {
1811
+ cwd: target,
1812
+ stdio: 'inherit',
1813
+ });
1814
+ return;
1815
+ } catch {
1816
+ process.exit(EXIT.ERR_UNKNOWN);
1817
+ }
1818
+ }
1819
+
1820
+ if (fs.existsSync(bootstrapSh)) {
1821
+ try {
1822
+ execSync(`bash "${bootstrapSh}"`, {
1823
+ cwd: target,
1824
+ stdio: 'inherit',
1825
+ });
1826
+ } catch {
1827
+ process.exit(EXIT.ERR_UNKNOWN);
1828
+ }
1829
+ return;
1830
+ }
1831
+
1832
+ die(`No bootstrap script found in ${target} — skills may not be installed.`, EXIT.ERR_NOT_INSTALLED);
1833
+ }
1834
+
1835
+ // ---- info ----
1836
+
1837
+ function cmdInfo(targetDir) {
1838
+ const target = path.resolve(targetDir || process.cwd());
1839
+
1840
+ console.log(`\n${green('psm — Package Info')}\n`);
1841
+ console.log(` Version ${VERSION}`);
1842
+ console.log(` Package dir ${PKG_DIR}`);
1843
+ console.log(` Node.js ${process.version}`);
1844
+ console.log(` OS ${process.platform} ${process.arch}`);
1845
+ console.log(` TTY ${process.stdout.isTTY ? 'yes' : 'no'}`);
1846
+
1847
+ // npm registry latest
1848
+ const latest = getLatestVersion();
1849
+ if (latest) {
1850
+ if (latest === VERSION) {
1851
+ console.log(` npm latest ${latest} ${green('(up to date)')}`);
1852
+ } else {
1853
+ console.log(` npm latest ${latest} ${yellow(`(yours: ${VERSION})`)}`);
1854
+ }
1855
+ } else {
1856
+ console.log(` npm latest ${dim('(unable to check)')}`);
1857
+ }
1858
+
1859
+ // Target project info
1860
+ console.log(`\n${green('Target project')}\n`);
1861
+ console.log(` Path ${target}`);
1862
+ console.log(` Type ${detectProjectType(target)}`);
1863
+
1864
+ // AI docs status
1865
+ const agentsPath = path.join(target, 'AGENTS.md');
1866
+ const claudePath = path.join(target, 'CLAUDE.md');
1867
+ console.log(` AGENTS.md ${fs.existsSync(agentsPath) ? green('present') : yellow('absent')}`);
1868
+ console.log(` CLAUDE.md ${fs.existsSync(claudePath) ? green('present') : yellow('absent')}`);
1869
+
1870
+ // Skills status
1871
+ if (isInstalled(target)) {
1872
+ const skillsDir = path.join(target, '.agents', 'skills');
1873
+ const count = fs.readdirSync(skillsDir).filter((e) => {
1874
+ return fs.statSync(path.join(skillsDir, e)).isDirectory();
1875
+ }).length;
1876
+ console.log(` Skills ${count} installed ${green('✓')}`);
1877
+
1878
+ // Check skill tree entry
1879
+ if (fs.existsSync(agentsPath)) {
1880
+ const content = fs.readFileSync(agentsPath, 'utf-8');
1881
+ console.log(` Skill tree ${hasLoadingChain(content) ? green('present') : yellow('missing')}`);
1882
+ }
1883
+ } else {
1884
+ console.log(` Skills ${yellow('not installed')}`);
1885
+ }
1886
+
1887
+ console.log();
1888
+ }
1889
+
1890
+ // ---- outdated ----
1891
+
1892
+ function cmdOutdated() {
1893
+ const latest = getLatestVersion();
1894
+ if (!latest) {
1895
+ die('Unable to check npm registry. Are you online?');
1896
+ }
1897
+
1898
+ if (latest === VERSION) {
1899
+ console.log(green(`psm v${VERSION} is up to date.`));
1900
+ process.exit(EXIT.OK);
1901
+ } else {
1902
+ console.log(yellow(`psm v${VERSION} installed, v${latest} available.`));
1903
+ console.log(cyan(' Run « npx psmgr update » to upgrade.'));
1904
+ process.exit(EXIT.ERR_OUTDATED);
1905
+ }
1906
+ }
1907
+
1908
+ // ---- update ----
1909
+
1910
+ function cmdUpdate() {
1911
+ const latest = getLatestVersion();
1912
+ if (!latest) {
1913
+ die('Unable to check npm registry. Are you online?');
1914
+ }
1915
+
1916
+ if (latest === VERSION) {
1917
+ console.log(green(`psm v${VERSION} is already the latest version.`));
1918
+ return;
1919
+ }
1920
+
1921
+ console.log(cyan(`Updating psm: v${VERSION} → v${latest} …`));
1922
+ try {
1923
+ execSync(`npm install -g psmgr@latest`, {
1924
+ stdio: 'inherit',
1925
+ });
1926
+ console.log(green(`Updated to v${latest}.`));
1927
+ } catch {
1928
+ die('Update failed. Try: npm install -g psmgr@latest');
1929
+ }
1930
+ }
1931
+
1932
+ // ---- Main ----
1933
+
1934
+ function main() {
1935
+ const args = process.argv.slice(2);
1936
+ const cmd = args[0] || '--help';
1937
+
1938
+ switch (cmd) {
1939
+ case 'install': {
1940
+ const yes = args.includes('-y') || args.includes('--yes');
1941
+ const preview = args.includes('--preview');
1942
+ const target = args.slice(1).find((a) => !a.startsWith('-'));
1943
+ // cmdInstall is now async
1944
+ cmdInstall(target, { yes, preview }).catch((err) => {
1945
+ die(`Install failed: ${err.message}`);
1946
+ });
1947
+ break;
1948
+ }
1949
+ case 'check':
1950
+ cmdCheck(args[1]);
1951
+ break;
1952
+ case 'info':
1953
+ cmdInfo(args[1]);
1954
+ break;
1955
+ case 'list':
1956
+ cmdList();
1957
+ break;
1958
+ case 'registry':
1959
+ cmdRegistry();
1960
+ break;
1961
+ case 'discover':
1962
+ cmdDiscover(args[1]);
1963
+ break;
1964
+ case 'tool': {
1965
+ const sub = args[1];
1966
+ if (sub === 'install') {
1967
+ const toolName = args[2];
1968
+ if (!toolName) die('用法: psm tool install <工具名> [target]');
1969
+ cmdToolInstall(toolName, args[3]);
1970
+ } else if (sub === 'list') {
1971
+ cmdToolList(args[2]);
1972
+ } else if (sub === 'setup') {
1973
+ cmdToolSetup(args[2]);
1974
+ } else if (sub === 'verify') {
1975
+ cmdToolVerify(args[2]);
1976
+ } else {
1977
+ die(`未知 tool 子命令: ${sub}。可用: install, list, setup, verify`);
1978
+ }
1979
+ break;
1980
+ }
1981
+ case 'outdated':
1982
+ cmdOutdated();
1983
+ break;
1984
+ case 'update':
1985
+ cmdUpdate();
1986
+ break;
1987
+ case 'version':
1988
+ case '--version':
1989
+ case '-v':
1990
+ cmdVersion();
1991
+ break;
1992
+ case 'help':
1993
+ case '--help':
1994
+ case '-h':
1995
+ cmdHelp();
1996
+ break;
1997
+ default:
1998
+ console.error(red(`Unknown command: ${cmd}`));
1999
+ cmdHelp();
2000
+ process.exit(EXIT.ERR_UNKNOWN);
2001
+ }
2002
+ }
2003
+
2004
+ main();