@tanlinfeng/project-docs-workflow 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +66 -1
  2. package/bin/install.js +471 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,12 +30,71 @@ npx @tanlinfeng/project-docs-workflow
30
30
  | `npx @tanlinfeng/project-docs-workflow --dir <path>` | 指定 skills 根目录(默认 `~/.agents/skills`) |
31
31
  | `npx @tanlinfeng/project-docs-workflow --force` | 覆盖已存在的 `project-docs-workflow`(**仅主 skill**;依赖 skill 仍不覆盖) |
32
32
  | `npx @tanlinfeng/project-docs-workflow --dry-run` | 只打印将执行的操作,不写入任何文件 |
33
+ | `npx @tanlinfeng/project-docs-workflow --with-matt-skills` | 可选:依赖 skill 改从上游 `mattpocock/skills` 安装**完整版**(需 Node >= 22.20) |
34
+ | `npx @tanlinfeng/project-docs-workflow --matt-agent <name>` | 上游安装使用的 agent(默认 `codex`,落盘到其全局目录 `~/.agents/skills`) |
33
35
  | `npx @tanlinfeng/project-docs-workflow check` | 检查依赖 skill 的解析状态,全部可解析时退出码为 0,否则为 1 |
36
+ | `npx @tanlinfeng/project-docs-workflow uninstall` | 依据**安装清单**卸载本包安装的 skill(只删本包装的,见下文「卸载」) |
37
+ | `npx @tanlinfeng/project-docs-workflow uninstall --dry-run` | 只打印卸载计划(将删除 / 保留哪些),不写入 |
38
+ | `npx @tanlinfeng/project-docs-workflow uninstall --force` | 跳过交互确认;非 TTY 场景下**必须**加此参数 |
34
39
 
35
40
  ### 覆盖策略(安全优先)
36
41
 
37
42
  安装器**永不覆盖用户已存在的同名 skill**。若目标目录中已存在同名目录,会打印 `同名 skill 已存在,保留用户版本:<name>` 并跳过。只有对主 skill `project-docs-workflow` 使用 `--force` 时才会覆盖它;依赖 skill 即使加了 `--force` 也仍会跳过。
38
43
 
44
+ ### 可选:从上游安装「完整版」依赖 skill
45
+
46
+ 包内自带的依赖 skill 是来自 `mattpocock/skills` 的**轻量版**。如果你更想要**完整上游版本**,可以启用 `--with-matt-skills`:安装器会调用官方 `skills` CLI(npm 包 `skills`)执行
47
+
48
+ ```bash
49
+ npx --yes skills@latest add mattpocock/skills -g -y -a <agent> -s <name1> <name2> ...
50
+ ```
51
+
52
+ - **交互式询问**:不带 `--with-matt-skills` 时,仅当 stdout 与 stdin **都是 TTY**、动作为 `install`(非 `check`)、且非 `--dry-run` 时,才提示一次 `是否从上游安装依赖 skill 的完整版(mattpocock/skills)?[y/N] `,**默认否**(空输入或其它输入均视为否)。非 TTY / 管道 / CI 场景**完全不询问**,不会挂起。
53
+ - **受管集合**:仅这 8 个名字参与上游安装:`grilling`、`grill-me`、`grill-with-docs`、`domain-modeling`、`tdd`、`deepwork`、`retro`、`improve-codebase-architecture`;`doc-opt` 与主 skill 不属于上游集合,维持原有行为。
54
+ - **永不覆盖**:调用上游前会先检测这 8 个名字里哪些在目标目录中**已存在**,已存在者**原样保留、绝不提交给上游**;只有缺失的名字才会通过 `-s` 传给上游。
55
+ - **顺序与去重**:先跑上游;随后复核上游目标目录,仍然缺失的名字才回退复制本包的轻量版。启用上游时,由上游负责的名字不会再复制轻量版;未启用时(默认)行为与之前完全一致。
56
+ - **降级、绝不失败**:Node < 22.20、找不到 `npx`、上游非 0 退出或网络错误时,只打印中文告警并回退到缺失项的轻量版安装,整体仍以退出码 0 结束。
57
+ - **agent 与目录注意**:上游固定以 `-a <agent> -g` 安装到该 agent 的**全局目录**(默认 `codex` → `~/.agents/skills`,正好与默认目标一致)。可用 `--matt-agent <name>` 覆盖 agent。若你传入了非默认的 `--dir`,上游部分**无法跟随 `--dir`**(仍落到 agent 全局目录),安装器会明确告警,同时 `--dir` 下的轻量版仍按原逻辑处理。
58
+ - **dry-run**:`--dry-run --with-matt-skills` 只打印将执行的上游命令、以及哪些名字因已存在而跳过,不实际执行。
59
+
60
+ ## 卸载
61
+
62
+ 安装时,本包会在 `<target>/.project-docs-workflow.install.json`(`<target>` 为 `--dir` 或默认 `~/.agents/skills`)写入一份**安装清单**,记录每个被处理过的 skill 及其**归属**。卸载完全依据这份清单,而不是靠猜测。
63
+
64
+ 清单形如:
65
+
66
+ ```json
67
+ {
68
+ "version": "2.1.0",
69
+ "target": "/home/me/.agents/skills",
70
+ "updatedAt": "2026-10-12T05:24:36.386Z",
71
+ "entries": {
72
+ "project-docs-workflow": { "kind": "main", "action": "installed", "at": "…" },
73
+ "tdd": { "kind": "lite", "action": "installed", "at": "…" },
74
+ "doc-opt": { "kind": "doc-opt", "action": "installed", "at": "…" },
75
+ "deepwork": { "kind": "lite", "action": "skipped", "at": "…" },
76
+ "retro": { "kind": "upstream", "action": "installed", "agent": "codex", "at": "…" }
77
+ }
78
+ }
79
+ ```
80
+
81
+ - `kind`:`main` / `lite` / `doc-opt`(本包复制的轻量版)/ `upstream`(委托上游 `skills` CLI 安装的完整版)。
82
+ - `action`:`installed`(本次由本包创建)、`forced`(被 `--force` 覆盖,仍属本包)、`skipped`(安装时已存在,本包原样保留,**永不删除**)。
83
+ - 每次成功 `install` 都会**合并更新**清单(不会丢失旧条目);`--dry-run` 不写清单。
84
+
85
+ **归属规则(安全优先)**:`uninstall` 只删除清单中 `action` 为 `installed` / `forced` 的名字——无论是本包复制的轻量版,还是本包委托上游安装的完整版。凡是安装时**已存在**(记为 `skipped`)的名字,一律不碰;首次记为 `skipped` 的名字即使后续再次安装也始终保持 `skipped`。
86
+
87
+ 用法:
88
+
89
+ ```bash
90
+ npx @tanlinfeng/project-docs-workflow uninstall [--dir <path>] [--dry-run] [--force]
91
+ ```
92
+
93
+ - **确认**:在交互式终端会询问一次 `确认卸载本包安装的 skill?[y/N] `,**默认否**。非交互式(管道 / CI / 非 TTY)**拒绝执行**,除非显式传入 `--force`。`--dry-run` 从不询问、从不写入。
94
+ - **删除方式**:`main` / `lite` / `doc-opt` 直接删除 `<target>/<name>` 目录。`upstream` 的完整版**优先委托上游** `skills` CLI 卸载(按 agent 批量):`npx --yes skills@latest remove <names> -g -y -a <agent>`;当无法委托时(Node < 22.20、找不到 `npx`、上游非 0 退出)会打印告警并**回退直接删除**目录,并提示上游锁文件 `~/.agents/.skill-lock.json` 可能残留过期条目,可用 `npx skills@latest list` 核对。
95
+ - **收尾**:处理完成后删除清单文件本身;**不会**删除目标目录本身,其它文件与空目录保持不动。
96
+ - **幂等**:没有可删项时打印友好提示并以退出码 0 结束。若**清单缺失**,会告警「无清单,尽力而为」,此时**仅**在 `project-docs-workflow` 存在时询问是否删除它,其余 skill 一律不动。
97
+
39
98
  ## 直接复制(不使用 npm 安装器)
40
99
 
41
100
  也可以完全跳过安装器,手动把 `skills/` 下需要的内容复制进你的 skills 根目录:
@@ -77,6 +136,8 @@ skill 在引用依赖能力时,遵循两级解析:
77
136
 
78
137
  部分内置 skill 内容摘自 <https://github.com/mattpocock/skills>(MIT License,Copyright (c) 2026 Matt Pocock),详见 [`NOTICE`](./NOTICE)。
79
138
 
139
+ 上表中的 8 个上游依赖 skill 都可以改用完整版:安装时加 `--with-matt-skills` 即会从上游安装(见上文「可选:从上游安装『完整版』依赖 skill」)。
140
+
80
141
  ## 版本说明
81
142
 
82
143
  本包版本 `2.x` 与「模板生成 v2」对齐:模板结构与生成约定以 v2 为准。
@@ -91,7 +152,11 @@ MIT,见 [`LICENSE`](./LICENSE)。
91
152
 
92
153
  `project-docs-workflow` is an npm-packaged agent **skill bundle** for project documentation workflows: issue tracking, ADRs, PDRs, a terminology glossary, and the workflow that ties them together. The main skill is self-contained via internal `references/deps/*.md` fallbacks, and ships with lightweight dependency skills (trimmed from [mattpocock/skills](https://github.com/mattpocock/skills), MIT, Matt Pocock) plus a bundled `doc-opt` bonus skill.
93
154
 
94
- Install with `npx @tanlinfeng/project-docs-workflow` (defaults to `~/.agents/skills`; options: `--dir <path>`, `--force`, `--dry-run`, and the `check` subcommand). The installer **never overwrites** an existing skill of the same name — it skips it, preserving your version. `--force` overwrites only the main `project-docs-workflow` skill.
155
+ Install with `npx @tanlinfeng/project-docs-workflow` (defaults to `~/.agents/skills`; options: `--dir <path>`, `--force`, `--dry-run`, `--with-matt-skills`, `--matt-agent <name>`, and the `check` / `uninstall` subcommands). The installer **never overwrites** an existing skill of the same name — it skips it, preserving your version. `--force` overwrites only the main `project-docs-workflow` skill.
156
+
157
+ Every successful install writes an **install manifest** at `<target>/.project-docs-workflow.install.json` recording, per skill name, the `kind` (`main`/`lite`/`doc-opt`/`upstream`) and `action` (`installed`/`forced`/`skipped`), merged across re-installs. `uninstall [--dir <path>] [--dry-run] [--force]` removes **only** the names this package owns (`installed`/`forced`) and never touches names that pre-existed (`skipped`, decided by the record, not by guessing). On a TTY it asks once (`[y/N]`, default No); on a non-TTY it refuses unless `--force` is given. Lite/main/doc-opt copies are deleted directly; upstream full versions are removed via the `skills` CLI (`npx --yes skills@latest remove <names> -g -y -a <agent>`), falling back to direct deletion with a warning about a possibly-stale `~/.agents/.skill-lock.json` if delegation is impossible. `--dry-run` prints the plan and never prompts or writes; the manifest is deleted afterwards, the target directory itself is left in place.
158
+
159
+ Optionally, `--with-matt-skills` installs the **full upstream** versions of the dependency skills from `mattpocock/skills` via the official `skills` CLI (`npx --yes skills@latest add mattpocock/skills -g -y -a <agent> -s ...`, requires Node >= 22.20). Without the flag, an interactive TTY gets a single prompt (default **No**); non-TTY/piped/CI runs never prompt. The 8 upstream-managed names are `grilling`, `grill-me`, `grill-with-docs`, `domain-modeling`, `tdd`, `deepwork`, `retro`, `improve-codebase-architecture`; `doc-opt` and the main skill are unaffected. Any name already present is left untouched and never sent upstream, and the installer re-checks afterwards, falling back to the bundled lite copy for anything still missing. If upstream can't run (Node too old, no `npx`, non-zero exit, network error), it warns and falls back — the install still exits 0. Upstream installs into the agent's global dir (`codex` → `~/.agents/skills`), so it cannot follow a custom `--dir` (a warning is printed).
95
160
 
96
161
  You can also copy `skills/*` directly into your skills root (skip any existing directory name, never overwrite); copying just `skills/project-docs-workflow` works standalone thanks to the internal ref fallbacks.
97
162
 
package/bin/install.js CHANGED
@@ -4,11 +4,35 @@
4
4
  const fs = require("fs");
5
5
  const os = require("os");
6
6
  const path = require("path");
7
+ const { spawnSync } = require("child_process");
7
8
 
8
9
  const PKG_ROOT = path.join(__dirname, "..");
9
10
  const SKILLS_DIR = path.join(PKG_ROOT, "skills");
10
11
  const MAIN_SKILL = "project-docs-workflow";
11
12
  const DEFAULT_TARGET = path.join(os.homedir(), ".agents", "skills");
13
+ // 安装清单:记录本包每次安装处理过的 skill 及其归属,供 uninstall 精确卸载。
14
+ const MANIFEST_NAME = ".project-docs-workflow.install.json";
15
+ const PKG_VERSION = require(path.join(PKG_ROOT, "package.json")).version;
16
+
17
+ // 可选上游:mattpocock/skills 的完整版依赖 skill。
18
+ // 官方安装器是 vercel-labs/skills 提供的 `skills` CLI(npm 包 skills),
19
+ // 固定以 `-a <agent> -g` 安装到该 agent 的全局目录(codex 为 ~/.agents/skills)。
20
+ const UPSTREAM_REPO = "mattpocock/skills";
21
+ const UPSTREAM_DEFAULT_AGENT = "codex";
22
+ // 上游安装器要求 Node >= 22.20(本项目不引入 semver 依赖,手工比较 major/minor)。
23
+ const UPSTREAM_MIN_NODE = { major: 22, minor: 20 };
24
+ // 仅这 8 个依赖 skill 属于上游集合;doc-opt 与主 skill 不属于。
25
+ const MATT_DEP_NAMES = [
26
+ "grilling",
27
+ "grill-me",
28
+ "grill-with-docs",
29
+ "domain-modeling",
30
+ "tdd",
31
+ "deepwork",
32
+ "retro",
33
+ "improve-codebase-architecture",
34
+ ];
35
+ const MATT_DEP_SET = new Set(MATT_DEP_NAMES);
12
36
 
13
37
  // 依赖 skill 的按名解析与 ref 兜底映射。
14
38
  // 顺序:先按名解析 <target>/<name>/SKILL.md,其次尝试 ref 兜底文件。
@@ -36,27 +60,55 @@ function usage() {
36
60
  "用法:",
37
61
  " project-docs-workflow [install] [选项] 安装 skill 到目标目录(默认动作)",
38
62
  " project-docs-workflow check [选项] 检查依赖 skill 的解析状态",
63
+ " project-docs-workflow uninstall [选项] 依据安装清单卸载本包安装的 skill",
39
64
  "",
40
65
  "选项:",
41
66
  " --dir <path> 指定目标 skills 根目录(默认 ~/.agents/skills)",
42
- " --force 覆盖已存在的 project-docs-workflow(仅主 skill;依赖仍不覆盖)",
67
+ " --force 覆盖已存在的 project-docs-workflow(仅主 skill;依赖仍不覆盖);",
68
+ " uninstall 时为非交互确认(非 TTY 下必须显式传入)",
43
69
  " --dry-run 仅打印将执行的操作,不实际写入",
70
+ " --with-matt-skills",
71
+ " 可选:改从上游 mattpocock/skills 安装依赖 skill 的完整版",
72
+ " (需 Node >= 22.20;无此 flag 时在交互式终端会询问一次,默认否)",
73
+ " --matt-agent <name>",
74
+ " 上游安装使用的 agent(默认 codex,落盘到其全局目录 ~/.agents/skills)",
44
75
  " -h, --help 显示本帮助",
45
76
  ].join("\n")
46
77
  );
47
78
  }
48
79
 
49
80
  function parseArgs(argv) {
50
- const opts = { command: "install", target: DEFAULT_TARGET, force: false, dryRun: false };
81
+ const opts = {
82
+ command: "install",
83
+ target: DEFAULT_TARGET,
84
+ force: false,
85
+ dryRun: false,
86
+ withMattSkills: false,
87
+ mattAgent: UPSTREAM_DEFAULT_AGENT,
88
+ };
51
89
  const positional = [];
52
90
  for (let i = 0; i < argv.length; i++) {
53
91
  const arg = argv[i];
54
- if (arg === "install" || arg === "check") {
92
+ if (arg === "install" || arg === "check" || arg === "uninstall") {
55
93
  opts.command = arg;
56
94
  } else if (arg === "--force") {
57
95
  opts.force = true;
58
96
  } else if (arg === "--dry-run") {
59
97
  opts.dryRun = true;
98
+ } else if (arg === "--with-matt-skills") {
99
+ opts.withMattSkills = true;
100
+ } else if (arg === "--matt-agent") {
101
+ const value = argv[++i];
102
+ if (!value) {
103
+ fail("--matt-agent 需要一个参数");
104
+ }
105
+ opts.mattAgent = value;
106
+ } else if (arg.startsWith("--matt-agent=")) {
107
+ const value = arg.slice("--matt-agent=".length);
108
+ if (!value) {
109
+ fail("--matt-agent 需要一个参数");
110
+ }
111
+ opts.mattAgent = value;
60
112
  } else if (arg === "--dir") {
61
113
  const value = argv[++i];
62
114
  if (!value) {
@@ -101,6 +153,104 @@ function isFile(p) {
101
153
  }
102
154
  }
103
155
 
156
+ // —— 上游(mattpocock/skills)相关辅助 ——
157
+
158
+ function existsAt(p) {
159
+ return isDir(p) || isFile(p);
160
+ }
161
+
162
+ // 手工解析 process.versions.node 并与 22.20 比较(不引入 semver 依赖)。
163
+ function nodeMeetsUpstream() {
164
+ const parts = String(process.versions.node).split(".");
165
+ const major = parseInt(parts[0], 10) || 0;
166
+ const minor = parseInt(parts[1], 10) || 0;
167
+ if (major !== UPSTREAM_MIN_NODE.major) {
168
+ return major > UPSTREAM_MIN_NODE.major;
169
+ }
170
+ return minor >= UPSTREAM_MIN_NODE.minor;
171
+ }
172
+
173
+ // 同步等待若干毫秒(用于 stdin 非阻塞时的短暂重试,避免忙等)。
174
+ function sleepSync(ms) {
175
+ try {
176
+ const sab = new SharedArrayBuffer(4);
177
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
178
+ } catch (e) {
179
+ /* 忽略:无法等待时直接继续 */
180
+ }
181
+ }
182
+
183
+ // 同步读取 stdin 的一行。仅在已确认 stdin 为 TTY 时调用。
184
+ function readLineSync() {
185
+ const buf = Buffer.alloc(1);
186
+ let line = "";
187
+ for (;;) {
188
+ let bytesRead = 0;
189
+ try {
190
+ bytesRead = fs.readSync(0, buf, 0, 1, null);
191
+ } catch (err) {
192
+ if (err && err.code === "EAGAIN") {
193
+ sleepSync(10);
194
+ continue;
195
+ }
196
+ if (err && (err.code === "EOF" || err.code === "ENXIO")) {
197
+ return line;
198
+ }
199
+ throw err;
200
+ }
201
+ if (bytesRead === 0) {
202
+ return line;
203
+ }
204
+ const ch = buf.toString("utf8", 0, 1);
205
+ if (ch === "\n") {
206
+ return line;
207
+ }
208
+ if (ch !== "\r") {
209
+ line += ch;
210
+ }
211
+ }
212
+ }
213
+
214
+ function npxCommand() {
215
+ return process.platform === "win32" ? "npx.cmd" : "npx";
216
+ }
217
+
218
+ // 组装上游命令参数(不含可执行文件名),以便打印或执行。
219
+ function buildUpstreamArgs(opts, names) {
220
+ return ["--yes", "skills@latest", "add", UPSTREAM_REPO, "-g", "-y", "-a", opts.mattAgent, "-s"].concat(
221
+ names
222
+ );
223
+ }
224
+
225
+ function quoteArg(arg) {
226
+ return /[\s"]/.test(arg) ? '"' + arg.replace(/"/g, '\\"') + '"' : arg;
227
+ }
228
+
229
+ function upstreamCommandString(opts, names) {
230
+ return [npxCommand()].concat(buildUpstreamArgs(opts, names)).map(quoteArg).join(" ");
231
+ }
232
+
233
+ // 决定是否启用上游安装。仅在 install 且非 dry-run、且 stdin/stdout 均为 TTY 时提示。
234
+ function decideUpstream(opts) {
235
+ if (opts.withMattSkills) {
236
+ return true;
237
+ }
238
+ if (opts.command !== "install" || opts.dryRun) {
239
+ return false;
240
+ }
241
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
242
+ return false;
243
+ }
244
+ process.stdout.write("是否从上游安装依赖 skill 的完整版(mattpocock/skills)?[y/N] ");
245
+ let answer = "";
246
+ try {
247
+ answer = readLineSync().trim().toLowerCase();
248
+ } catch (e) {
249
+ answer = "";
250
+ }
251
+ return answer === "y" || answer === "yes";
252
+ }
253
+
104
254
  // 列出 skills/ 下的所有子目录名;缺失时返回空数组(由调用方给出提示)。
105
255
  function listSkillDirs() {
106
256
  if (!isDir(SKILLS_DIR)) {
@@ -123,6 +273,130 @@ function copySkill(name, dest, dryRun) {
123
273
  console.log(" ✓ 已安装 " + name + " → " + dest);
124
274
  }
125
275
 
276
+ // —— 安装清单(manifest)——
277
+
278
+ function manifestPath(target) {
279
+ return path.join(target, MANIFEST_NAME);
280
+ }
281
+
282
+ function loadManifest(target) {
283
+ const p = manifestPath(target);
284
+ if (!isFile(p)) {
285
+ return null;
286
+ }
287
+ try {
288
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
289
+ if (data && typeof data === "object" && data.entries && typeof data.entries === "object") {
290
+ return data;
291
+ }
292
+ } catch (e) {
293
+ /* 忽略:清单损坏时按缺失处理 */
294
+ }
295
+ return null;
296
+ }
297
+
298
+ // 合并新旧条目:归属一旦记在本包名下(installed/forced),后续因「已存在」而 skip 时不被降级;
299
+ // 而初次即为 skipped(安装时已存在、非本包安装)的名字始终保持 skipped。
300
+ function mergeEntries(prev, fresh) {
301
+ const out = Object.assign({}, prev);
302
+ for (const name of Object.keys(fresh)) {
303
+ const f = fresh[name];
304
+ const p = out[name];
305
+ if (f.action === "skipped" && p && p.action !== "skipped") {
306
+ continue;
307
+ }
308
+ out[name] = f;
309
+ }
310
+ return out;
311
+ }
312
+
313
+ function writeManifest(target, entries) {
314
+ const p = manifestPath(target);
315
+ const data = {
316
+ version: PKG_VERSION,
317
+ target: path.resolve(target),
318
+ updatedAt: new Date().toISOString(),
319
+ entries,
320
+ };
321
+ fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
322
+ console.log("已写入安装清单:" + p);
323
+ }
324
+
325
+ function removeManifest(target) {
326
+ const p = manifestPath(target);
327
+ if (!isFile(p)) {
328
+ return;
329
+ }
330
+ try {
331
+ fs.rmSync(p, { force: true });
332
+ console.log("已删除安装清单:" + p);
333
+ } catch (e) {
334
+ console.log("⚠ 无法删除安装清单:" + p + "(" + e.message + ")");
335
+ }
336
+ }
337
+
338
+ // 依赖 skill 的 kind:doc-opt 单列,其余依赖(含其它 skills/* 目录)视为 lite。
339
+ function depKind(name) {
340
+ return name === "doc-opt" ? "doc-opt" : "lite";
341
+ }
342
+
343
+ // —— 上游卸载命令组装 ——
344
+
345
+ function buildUpstreamRemoveArgs(names, agent) {
346
+ return ["--yes", "skills@latest", "remove"].concat(names).concat(["-g", "-y", "-a", agent]);
347
+ }
348
+
349
+ function upstreamRemoveString(names, agent) {
350
+ return [npxCommand()].concat(buildUpstreamRemoveArgs(names, agent)).map(quoteArg).join(" ");
351
+ }
352
+
353
+ function groupByAgent(list) {
354
+ const map = new Map();
355
+ for (const item of list) {
356
+ if (!map.has(item.agent)) {
357
+ map.set(item.agent, []);
358
+ }
359
+ map.get(item.agent).push(item.name);
360
+ }
361
+ return map;
362
+ }
363
+
364
+ // 优先委托上游 skills CLI 卸载;不可用时(Node 过旧 / npx 缺失 / 非 0 退出)回退直接删除目录。
365
+ function removeUpstreamWithFallback(names, agent, target) {
366
+ const directFallback = (reason) => {
367
+ console.log(" ⚠ " + reason);
368
+ for (const name of names) {
369
+ const dest = path.join(target, name);
370
+ if (existsAt(dest)) {
371
+ fs.rmSync(dest, { recursive: true, force: true });
372
+ console.log(" ✓ 已直接删除 " + name);
373
+ } else {
374
+ console.log(" - 未找到 " + name + "(跳过)");
375
+ }
376
+ }
377
+ console.log(
378
+ " 注意:上游锁文件 ~/.agents/.skill-lock.json 可能残留过期条目;可用 `npx skills@latest list` 核对。"
379
+ );
380
+ };
381
+
382
+ if (!nodeMeetsUpstream()) {
383
+ directFallback("当前 Node " + process.versions.node + " < 22.20,无法运行上游卸载器。");
384
+ return;
385
+ }
386
+ console.log(" 将执行:" + upstreamRemoveString(names, agent));
387
+ const res = spawnSync(npxCommand(), buildUpstreamRemoveArgs(names, agent), {
388
+ stdio: "inherit",
389
+ env: Object.assign({}, process.env, { DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }),
390
+ });
391
+ if (res.error) {
392
+ directFallback("上游卸载无法执行(" + (res.error.code || res.error.message) + ")。");
393
+ } else if (res.status !== 0) {
394
+ directFallback("上游卸载失败(退出码 " + res.status + ")。");
395
+ } else {
396
+ console.log(" ✓ 已通过 skills CLI 卸载:" + names.join("、"));
397
+ }
398
+ }
399
+
126
400
  function runInstall(opts) {
127
401
  const dirs = listSkillDirs();
128
402
  if (dirs === null || dirs.length === 0) {
@@ -136,6 +410,15 @@ function runInstall(opts) {
136
410
  fail("缺少主 skill 目录:skills/" + MAIN_SKILL);
137
411
  }
138
412
 
413
+ // 是否启用上游完整版安装(可能触发一次交互式询问,仅限 TTY + install + 非 dry-run)。
414
+ const upstreamEnabled = decideUpstream(opts);
415
+
416
+ const nowIso = new Date().toISOString();
417
+ const freshEntries = {};
418
+ const record = (name, entry) => {
419
+ freshEntries[name] = Object.assign({ at: nowIso }, entry);
420
+ };
421
+
139
422
  const target = opts.target;
140
423
  console.log("目标目录:" + target + (opts.dryRun ? "(dry-run,不会写入)" : ""));
141
424
  if (!opts.dryRun) {
@@ -148,6 +431,7 @@ function runInstall(opts) {
148
431
  console.log("主 skill:" + MAIN_SKILL);
149
432
  if (mainExists && !opts.force) {
150
433
  console.log(" 同名 skill 已存在,保留用户版本:" + MAIN_SKILL + "(如需覆盖请加 --force)");
434
+ record(MAIN_SKILL, { kind: "main", action: "skipped" });
151
435
  } else if (mainExists && opts.force) {
152
436
  if (opts.dryRun) {
153
437
  console.log(" [dry-run] 将覆盖 " + MAIN_SKILL + " → " + mainDest);
@@ -155,8 +439,59 @@ function runInstall(opts) {
155
439
  fs.rmSync(mainDest, { recursive: true, force: true });
156
440
  copySkill(MAIN_SKILL, mainDest, false);
157
441
  }
442
+ record(MAIN_SKILL, { kind: "main", action: "forced" });
158
443
  } else {
159
444
  copySkill(MAIN_SKILL, mainDest, opts.dryRun);
445
+ record(MAIN_SKILL, { kind: "main", action: "installed" });
446
+ }
447
+
448
+ // 可选:先从上游安装 8 个依赖 skill 的完整版。
449
+ // upstreamHandled 记录「本次由上游负责」的名字,这些名字不再复制本包轻量版。
450
+ let upstreamHandled = new Set();
451
+ if (upstreamEnabled) {
452
+ console.log("上游依赖 skill(mattpocock/skills 完整版):");
453
+ if (opts.target !== DEFAULT_TARGET) {
454
+ console.log(
455
+ " ⚠ 检测到自定义 --dir:上游安装固定使用 agent 全局目录(" +
456
+ opts.mattAgent +
457
+ " → ~/.agents/skills),无法跟随 --dir;--dir 下的轻量版仍按原逻辑处理。"
458
+ );
459
+ }
460
+ const preexisting = MATT_DEP_NAMES.filter((name) => existsAt(path.join(target, name)));
461
+ const missing = MATT_DEP_NAMES.filter((name) => preexisting.indexOf(name) === -1);
462
+ if (preexisting.length > 0) {
463
+ console.log(" 已存在,保留且不提交上游:" + preexisting.join("、"));
464
+ }
465
+ if (missing.length === 0) {
466
+ console.log(" 8 个依赖 skill 均已在目标目录,无需上游安装。");
467
+ } else if (opts.dryRun) {
468
+ console.log(" [dry-run] 将执行:" + upstreamCommandString(opts, missing));
469
+ upstreamHandled = new Set(missing);
470
+ } else if (!nodeMeetsUpstream()) {
471
+ console.log(
472
+ " ⚠ 当前 Node " +
473
+ process.versions.node +
474
+ " < 22.20,无法运行上游安装器;将对缺失项回退轻量版。"
475
+ );
476
+ } else {
477
+ console.log(" 将执行:" + upstreamCommandString(opts, missing));
478
+ const res = spawnSync(npxCommand(), buildUpstreamArgs(opts, missing), {
479
+ stdio: "inherit",
480
+ env: Object.assign({}, process.env, { DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1" }),
481
+ });
482
+ if (res.error) {
483
+ console.log(
484
+ " ⚠ 上游安装无法执行(" +
485
+ (res.error.code || res.error.message) +
486
+ ");将对缺失项回退轻量版。"
487
+ );
488
+ } else if (res.status !== 0) {
489
+ console.log(" ⚠ 上游安装失败(退出码 " + res.status + ");将对缺失项回退轻量版。");
490
+ }
491
+ // 复核:仅把「本次新增」的名字标记为已由上游处理;已存在者走下面的保留逻辑。
492
+ const nowPresent = MATT_DEP_NAMES.filter((name) => existsAt(path.join(target, name)));
493
+ upstreamHandled = new Set(nowPresent.filter((name) => missing.indexOf(name) !== -1));
494
+ }
160
495
  }
161
496
 
162
497
  // 其他依赖 skill:永不覆盖。
@@ -166,21 +501,152 @@ function runInstall(opts) {
166
501
  console.log(" (未发现其他目录)");
167
502
  }
168
503
  for (const name of others) {
504
+ if (upstreamEnabled && MATT_DEP_SET.has(name) && upstreamHandled.has(name)) {
505
+ console.log(
506
+ (opts.dryRun
507
+ ? " [dry-run] 将由上游安装完整版,跳过轻量版:"
508
+ : " 已由上游安装完整版,跳过轻量版:") + name
509
+ );
510
+ record(name, { kind: "upstream", action: "installed", agent: opts.mattAgent });
511
+ continue;
512
+ }
169
513
  const dest = path.join(target, name);
170
514
  if (isDir(dest) || isFile(dest)) {
171
515
  console.log(" 同名 skill 已存在,保留用户版本:" + name);
516
+ record(name, { kind: depKind(name), action: "skipped" });
172
517
  continue;
173
518
  }
174
519
  copySkill(name, dest, opts.dryRun);
520
+ record(name, { kind: depKind(name), action: "installed" });
175
521
  }
176
522
 
177
523
  if (opts.dryRun) {
178
524
  console.log("dry-run 完成,未写入任何文件。");
179
525
  } else {
526
+ const prev = loadManifest(target);
527
+ const merged = mergeEntries(prev ? prev.entries : {}, freshEntries);
528
+ writeManifest(target, merged);
180
529
  console.log("安装完成。");
181
530
  }
182
531
  }
183
532
 
533
+ function confirmUninstall(opts) {
534
+ // 返回 "yes"|"no"|"refuse"。--force 直接视为 yes。
535
+ if (opts.force) {
536
+ return "yes";
537
+ }
538
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
539
+ return "refuse";
540
+ }
541
+ process.stdout.write("确认卸载本包安装的 skill?[y/N] ");
542
+ let answer = "";
543
+ try {
544
+ answer = readLineSync().trim().toLowerCase();
545
+ } catch (e) {
546
+ answer = "";
547
+ }
548
+ return answer === "y" || answer === "yes" ? "yes" : "no";
549
+ }
550
+
551
+ function runUninstall(opts) {
552
+ const target = opts.target;
553
+ const manifest = loadManifest(target);
554
+
555
+ // 无清单:尽力而为,仅处理主 skill。
556
+ if (!manifest) {
557
+ console.log("⚠ 未找到安装清单:" + manifestPath(target) + "(无清单,尽力而为)");
558
+ const mainDest = path.join(target, MAIN_SKILL);
559
+ if (!existsAt(mainDest)) {
560
+ console.log("目标目录中不存在 " + MAIN_SKILL + ",无需卸载。");
561
+ process.exit(0);
562
+ }
563
+ console.log("仅处理主 skill:" + MAIN_SKILL + "(其余 skill 保持不动)");
564
+ if (opts.dryRun) {
565
+ console.log(" [dry-run] 将删除 " + MAIN_SKILL + " → " + mainDest);
566
+ console.log("dry-run 完成,未写入任何文件。");
567
+ process.exit(0);
568
+ }
569
+ const decision = confirmUninstall(opts);
570
+ if (decision === "refuse") {
571
+ console.log("非交互式终端:为避免误删,已拒绝卸载。如确认请显式传入 --force。");
572
+ process.exit(1);
573
+ }
574
+ if (decision === "no") {
575
+ console.log("已取消卸载。");
576
+ process.exit(0);
577
+ }
578
+ fs.rmSync(mainDest, { recursive: true, force: true });
579
+ console.log(" ✓ 已删除 " + MAIN_SKILL);
580
+ console.log("清单缺失:其余 skill 一律保持不动。");
581
+ process.exit(0);
582
+ }
583
+
584
+ // 依据清单归属构建计划:record 说「本包安装」才删,skipped 永不删。
585
+ const entries = manifest.entries || {};
586
+ const removeLocal = [];
587
+ const removeUpstream = [];
588
+ const keep = [];
589
+ for (const name of Object.keys(entries).sort()) {
590
+ const e = entries[name] || {};
591
+ if (e.action === "installed" || e.action === "forced") {
592
+ if (e.kind === "upstream") {
593
+ removeUpstream.push({ name, agent: e.agent || UPSTREAM_DEFAULT_AGENT });
594
+ } else {
595
+ removeLocal.push(name);
596
+ }
597
+ } else {
598
+ // skipped 或未知状态:一律保留。
599
+ keep.push(name);
600
+ }
601
+ }
602
+
603
+ console.log("目标目录:" + target + (opts.dryRun ? "(dry-run,不会写入)" : ""));
604
+ const allRemove = removeLocal.concat(removeUpstream.map((u) => u.name));
605
+ console.log("将移除(本包安装):" + (allRemove.length ? allRemove.join("、") : "(无)"));
606
+ for (const name of keep) {
607
+ console.log("保留(安装时已存在,非本包安装):" + name);
608
+ }
609
+
610
+ if (allRemove.length === 0) {
611
+ console.log("没有需要移除的 skill(清单中无本包安装项)。");
612
+ process.exit(0);
613
+ }
614
+
615
+ if (opts.dryRun) {
616
+ for (const name of removeLocal) {
617
+ console.log(" [dry-run] 将删除 " + name + " → " + path.join(target, name));
618
+ }
619
+ for (const [agent, names] of groupByAgent(removeUpstream)) {
620
+ console.log(" [dry-run] 上游完整版(" + agent + "):" + upstreamRemoveString(names, agent));
621
+ }
622
+ console.log(" [dry-run] 将删除安装清单:" + manifestPath(target));
623
+ console.log("dry-run 完成,未写入任何文件。");
624
+ process.exit(0);
625
+ }
626
+
627
+ const decision = confirmUninstall(opts);
628
+ if (decision === "refuse") {
629
+ console.log("非交互式终端:为避免误删,已拒绝卸载。如确认请显式传入 --force。");
630
+ process.exit(1);
631
+ }
632
+ if (decision === "no") {
633
+ console.log("已取消卸载。");
634
+ process.exit(0);
635
+ }
636
+
637
+ for (const name of removeLocal) {
638
+ fs.rmSync(path.join(target, name), { recursive: true, force: true });
639
+ console.log(" ✓ 已删除 " + name);
640
+ }
641
+ for (const [agent, names] of groupByAgent(removeUpstream)) {
642
+ console.log("上游完整版(" + agent + "):");
643
+ removeUpstreamWithFallback(names, agent, target);
644
+ }
645
+ removeManifest(target);
646
+ console.log("卸载完成。");
647
+ process.exit(0);
648
+ }
649
+
184
650
  function runCheck(opts) {
185
651
  const target = opts.target;
186
652
  console.log("检查依赖 skill 解析状态(目标目录:" + target + "):");
@@ -219,6 +685,8 @@ function main() {
219
685
  const opts = parseArgs(process.argv.slice(2));
220
686
  if (opts.command === "check") {
221
687
  runCheck(opts);
688
+ } else if (opts.command === "uninstall") {
689
+ runUninstall(opts);
222
690
  } else {
223
691
  runInstall(opts);
224
692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanlinfeng/project-docs-workflow",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },