nx-kv 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.5.0] - 2026-10-08
6
+
7
+ ### Added
8
+
9
+ - **skill 命令补齐为完整多 skill 形态(严格按脚手架 B04)**:
10
+ - `nx-kv skill list`:列出包内可装 skill(nx-kv / kv-sessiontask)、默认安装项与可装 group;
11
+ - `nx-kv skill groups`:列出 groups.json 的 group → 其包含的 skill;
12
+ - `nx-kv skill install [name] [--to <dir>] [--force]`:显式装某个 skill;
13
+ - `nx-kv skill install --group=<key> [--to <dir>] [--force]`:一键装整组(`<name>` 与
14
+ `--group` 互斥,同时给报 INVALID_INPUT);
15
+ - `nx-kv skill get [name] [ref] [--to <dir>]` / `--group=<key>`:输出 skill 全文(prefix +
16
+ sentinel + 正文 + install 状态),供外部 agent 一键拿全上下文。
17
+ - 新增 `assets/groups.json`(默认 group key = nx-kv);缺失/损坏时降级为目录扫描,
18
+ schema 错显式报错,不静默。
19
+ - HTTP 新增 `GET /api/bundled/groups`、`GET /api/bundled/content`。
20
+ - 新增单测 `bundled-multiskill.test.mjs`(20 项断言:三种 install、默认标记、降级、
21
+ schema 错、幂等、部分冲突聚合、get 整组);全套 50 项全绿。
22
+
5
23
  ## [0.4.1] - 2026-10-08
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -166,7 +166,10 @@ assets/kv-sessiontask/ 内置 skill:对话内实时任务接力
166
166
  ```bash
167
167
  nx-kv skill install # 装到 ~/.claude/skills/nx-kv(KV 清单操作手册)
168
168
  nx-kv skill install kv-sessiontask # 实时任务接力:最后一步阻塞等用户喂任务
169
- nx-kv skill list # 列出 nx-kv / kv-sessiontask
169
+ nx-kv skill list # 列出可装 skill、默认安装项与可装 group
170
+ nx-kv skill groups # 列出 group → 包含哪些 skill
171
+ nx-kv skill install --group=<key> # 一键装整组(与 <name> 二选一)
172
+ nx-kv skill get [name] [ref] # 输出 skill 全文,外部 agent 一键拿上下文
170
173
  ```
171
174
 
172
175
  装好后 agent 就能按 skill 里的 SOP 领任务、读主题上下文、完成后回填结果;
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "$comment": "group 名 → skill 名列表的别名表。当前 group 名 ≡ skill 名(一对一);skills 是数组,为「一个 group 含多个 skill」预留。路径恒由 assets/<skills[i]>/ 推导——本文件只做聚合,不是第二条事实源。",
4
+ "groups": {
5
+ "nx-kv": {
6
+ "skills": ["nx-kv"],
7
+ "summary": "KV 清单管理中枢主 skill(默认 install 装的就是这个):todo / group / 工作空间"
8
+ },
9
+ "kv-sessiontask": {
10
+ "skills": ["kv-sessiontask"],
11
+ "summary": "本机实时人机接力(sessiontask)——agent 阻塞 wait,用户在网页回填"
12
+ }
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "nx-kv — KV 清单管理中枢 + 对话内实时任务接力:serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 领取任务与阻塞接收用户输入",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,63 @@
1
- // 内置 skill 包模块:把随包发布的 nx-kv 说明装到用户的 skills 目录。
2
- // 没有面板视图——安装入口在 Skill 页,列出/安装都通过 action 暴露。
1
+ // 内置 skill 包模块:把随包发布的 skill 说明装到用户的 skills 目录。
2
+ // 没有面板视图——列出/安装/取全文都通过 action 暴露(CLI 与 HTTP 同源)。
3
+ //
4
+ // 多 skill(见脚手架 B04):`assets/` 下可以有多个 skill,由 `assets/groups.json`
5
+ // 归组;`skill install --group <key>` 一键装一组。groups.json 只是**便捷别名表**,
6
+ // 缺失/损坏时降级为「每个 assets/<x> 各成一组」——事实源永远只有 `assets/<x>/SKILL.md`。
3
7
  import * as service from './service.js';
8
+ import { badInput } from '../../core/errors.js';
9
+
10
+ // <name> 与 --group 互斥:二者语义等价,报错比替用户猜意图友好(B04 §CLI 形状)
11
+ function exclusive(ctx, cmd) {
12
+ if (ctx.group !== undefined && ctx.group !== null && ctx.group !== '' && ctx.name) {
13
+ throw badInput(`${cmd} 的 <name> 与 --group 只能给一个`);
14
+ }
15
+ }
16
+
17
+ const GROUP_FLAG = { type: 'string', hint: 'groups.json 里的组名(与 <name> 互斥)' };
18
+
19
+ // `skill list` 的人读渲染(B04 §skill list 的输出)
20
+ function renderList(d) {
21
+ const def = d.defaultGroup ? (d.groupSkills[d.defaultGroup] || []) : [];
22
+ const w = Math.max(0, ...d.skills.map((s) => s.length));
23
+ const lines = ['可装的 skill:'];
24
+ for (const s of d.skills) {
25
+ lines.push(def.includes(s) ? ` * ${s.padEnd(w)} (默认 install)` : ` * ${s}`);
26
+ }
27
+ lines.push('可装的 group:');
28
+ for (const g of d.groups) {
29
+ lines.push(d.summaries[g] ? ` * ${g.padEnd(w)} ${d.summaries[g]}` : ` * ${g}`);
30
+ }
31
+ if (d.source === 'assets-dirs') {
32
+ lines.push('', '(assets/groups.json 缺失或损坏,已降级为目录扫描:每个内置 skill 各成一组)');
33
+ }
34
+ return lines.join('\n');
35
+ }
36
+
37
+ // get 的三段拼接(顺序固定):prefix 在最前告诉 agent 文件位置与「复制到自己可访问路径」;
38
+ // sentinel 让 agent 知道正文从哪行开始,不把引导语一起带进下游上下文。
39
+ // --json 不走这里(机器协议,不含 prefix)。
40
+ function renderContent(d) {
41
+ const i = d.install;
42
+ const inst = i.status === 'conflict'
43
+ ? `冲突: ${i.path}(${i.count} 个文件不同)—— 需要覆盖时用 nx-kv skill install --force`
44
+ : i.skipped
45
+ ? `已是最新: ${i.path}(无差异)`
46
+ : i.replaced
47
+ ? `已替换: ${i.path}`
48
+ : `已安装: ${i.path}`;
49
+ return [
50
+ `# === ${d.skillName} skill context ===`,
51
+ `# 以下内容来自 nx-kv skill \`${d.skillName}\` 的 ${d.ref}。`,
52
+ '# 建议:把 sentinel 之后的正文完整复制到你自己可访问的路径,再按其内容操作。',
53
+ '# --- begin skill content (do not modify this line) ---',
54
+ '',
55
+ d.content.replace(/\s*$/, ''),
56
+ '',
57
+ '-- install 状态 --',
58
+ inst,
59
+ ].join('\n');
60
+ }
4
61
 
5
62
  export default {
6
63
  id: 'bundled',
@@ -11,34 +68,78 @@ export default {
11
68
  actions: [
12
69
  {
13
70
  id: 'bundled.list',
14
- // 第二条是历史别名:`skill install --list` 早于 `bundled list` 存在,
71
+ // `skill list` 是 B04 的强制命令面(列出**包内**可装的 skill)。
72
+ // 后两条是历史别名:`skill install --list` 早于 `bundled list` 存在,
15
73
  // 命令路径里带 flag,因此匹配是按 token 前缀而非「非 flag 前缀」。
16
- cli: [['bundled', 'list'], ['skill', 'install', '--list']],
74
+ cli: [['skill', 'list'], ['skill', 'install', '--list'], ['bundled', 'list']],
17
75
  http: ['GET', '/api/bundled'],
18
- summary: '列出包内自带 skill(含文件数与描述)',
19
- // 两端返回结构刻意不同:HTTP 供面板顺带拿到默认安装目录;
20
- // CLI 的 --json 保持纯数组的历史形状,不破坏已在消费它的 agent。
76
+ summary: '列出包内自带 skill(含文件数与描述)+ 默认装哪个 + 有哪些 group',
77
+ // 两端返回结构刻意不同:HTTP 供面板顺带拿到默认安装目录与每个 skill 的文件数;
78
+ // CLI 的 --json 走 B04 的形状(skills 是**名字数组** + defaultGroup/groups/source)。
21
79
  run: async (_ctx, meta) => {
22
- const skills = await service.listBundledSkills();
23
- return meta.transport === 'http'
24
- ? { defaultDir: service.DEFAULT_SKILLS_DIR, skills }
25
- : skills;
80
+ const info = await service.groupsInfo();
81
+ if (meta.transport !== 'http') return info;
82
+ const details = await service.listBundledSkills();
83
+ // 展开顺序要紧:info.skills 是**名字数组**,details 是**对象数组**
84
+ // ({name,dir,files,description})。先铺 info、再压 details,否则
85
+ // skills 会被覆盖回字符串数组 —— 面板与 smoke 读 skills[].name 全变 undefined。
86
+ return {
87
+ ...info,
88
+ defaultDir: service.DEFAULT_SKILLS_DIR,
89
+ // smoke 与面板都在读 skills[].name —— HTTP 侧保持这个形状
90
+ skills: details,
91
+ details,
92
+ };
93
+ },
94
+ render: renderList,
95
+ },
96
+ {
97
+ id: 'bundled.groups',
98
+ cli: [['skill', 'groups'], ['bundled', 'groups']],
99
+ http: ['GET', '/api/bundled/groups'],
100
+ summary: '列出可装的 group(groups.json 的组名 → 它包含哪些 skill)',
101
+ run: () => service.groupsInfo(),
102
+ render: (d) => {
103
+ const head = `可装的 group(来源: ${d.source}${d.defaultGroup ? ` · 默认组: ${d.defaultGroup}` : ''}):`;
104
+ const lines = [head];
105
+ for (const g of d.groups) {
106
+ lines.push(` ${g}${d.summaries[g] ? ' —— ' + d.summaries[g] : ''}`);
107
+ lines.push(` skills: ${(d.groupSkills[g] || []).join(', ')}`);
108
+ }
109
+ if (!d.groups.length) lines.push(' (无)');
110
+ return lines.join('\n');
26
111
  },
27
- render: (l) =>
28
- l.length
29
- ? l.map((s) => `${s.name.padEnd(14)} ${s.files} 个文件 ${s.description.slice(0, 50)}`).join('\n')
30
- : '(包内无内置 skill)',
31
112
  },
32
113
  {
33
114
  id: 'bundled.install',
34
115
  cli: ['skill', 'install'],
35
116
  http: ['POST', '/api/bundled/install'],
36
- summary: '安装内置 skill 到 skills 目录(默认 nx-kv → ~/.claude/skills)',
117
+ summary: '安装内置 skill 到 skills 目录(默认 nx-kv → ~/.claude/skills;--group <key> 一键装一组)',
37
118
  args: [{ name: 'name', required: false }],
38
- flags: { to: { type: 'string' }, force: { type: 'boolean' } },
39
- run: (ctx) =>
40
- service.installBundledSkill({ name: ctx.name || 'nx-kv', to: ctx.to, force: ctx.force }),
119
+ flags: { group: GROUP_FLAG, to: { type: 'string' }, force: { type: 'boolean' } },
120
+ run: (ctx) => {
121
+ exclusive(ctx, 'skill install');
122
+ return service.installBundledSkill({
123
+ name: ctx.name || service.DEFAULT_SKILL_NAME,
124
+ group: ctx.group,
125
+ to: ctx.to,
126
+ force: ctx.force,
127
+ });
128
+ },
41
129
  render: (d) => {
130
+ // 聚合形状用**显式 group 字段**判别,不做形状嗅探(B04 §实现要点)
131
+ if (d.group) {
132
+ const done = d.skills.filter((r) => r.status === 'ok').length;
133
+ const lines = [`group ${d.group}: ${done}/${d.skills.length} 已就绪`];
134
+ for (const r of d.skills) {
135
+ const what = r.status === 'conflict'
136
+ ? `冲突(${r.count} 个文件不同)→ 用 --force 覆盖`
137
+ : r.skipped ? '已是最新'
138
+ : `${r.replaced ? '已更新' : '已安装'}(${r.files} 个文件)-> ${r.path}`;
139
+ lines.push(` ${r.name.padEnd(16)} ${what}`);
140
+ }
141
+ return lines.join('\n');
142
+ }
42
143
  if (d.status === 'conflict') {
43
144
  const files = d.files.map((f) => ` ${f.file} ${f.side}`).join('\n');
44
145
  return `目标已存在且内容不同(${d.count} 个文件): ${d.path}\n${files}\n用 --force 覆盖,或先删除目标目录`;
@@ -47,5 +148,22 @@ export default {
47
148
  return `${d.replaced ? '已更新' : '已安装'} ${d.name}(${d.files} 个文件)-> ${d.path}`;
48
149
  },
49
150
  },
151
+ {
152
+ // A03 §一/§三:get 与 install 是互补能力——install 让本机 agent 学得会用,
153
+ // get 让**不读 ~/.claude/skills 的外部 agent**一键拿全上下文。缺一即单边能力打折。
154
+ id: 'bundled.get',
155
+ cli: ['skill', 'get'],
156
+ http: ['GET', '/api/bundled/content'],
157
+ summary: '输出内置 skill 全文(prefix + sentinel + 正文 + install 状态),并顺手装到本机',
158
+ args: [{ name: 'name', required: false }, { name: 'ref', required: false }],
159
+ flags: { group: GROUP_FLAG, to: { type: 'string' } },
160
+ run: (ctx) => {
161
+ exclusive(ctx, 'skill get');
162
+ return service.bundledSkillContent({ name: ctx.name, group: ctx.group, ref: ctx.ref, to: ctx.to });
163
+ },
164
+ render: (d) => (d.group
165
+ ? d.skills.map(renderContent).join('\n\n' + '─'.repeat(60) + '\n\n')
166
+ : renderContent(d)),
167
+ },
50
168
  ],
51
169
  };
@@ -1,5 +1,9 @@
1
1
  // 内置 skill 包:随 nx-kv 一起发布的 assets/<name>/(SKILL.md + references/…)
2
2
  // 负责列出与安装到用户/项目的 skills 目录,供 agent 一键获得 nx-kv 的使用说明。
3
+ //
4
+ // 多 skill(见脚手架 B04):`assets/` 下可以有多个 skill,由 `assets/groups.json`
5
+ // 归组;`skill install --group <key>` 一键装一组。groups.json 只是**便捷别名表**,
6
+ // 缺失/损坏时降级为「每个 assets/<x> 各成一组」——事实源永远只有 `assets/<x>/SKILL.md`。
3
7
  import { homedir } from 'node:os';
4
8
  import { join, resolve } from 'node:path';
5
9
  import { fileURLToPath } from 'node:url';
@@ -7,14 +11,23 @@ import fsp from 'node:fs/promises';
7
11
  import { diffTrees } from '../../core/fstree.js';
8
12
  import { parseFrontmatter } from '../../core/frontmatter.js';
9
13
  import { assertSafeName } from '../../core/paths.js';
10
- import { notFound } from '../../core/errors.js';
14
+ import { notFound, badInput } from '../../core/errors.js';
11
15
 
12
16
  // assets/ 在包根,src/modules/bundled/ 的上三级
13
- const ASSETS_DIR = fileURLToPath(new URL('../../../assets/', import.meta.url));
17
+ const DEFAULT_ASSETS_DIR = fileURLToPath(new URL('../../../assets/', import.meta.url));
18
+
19
+ // assets 根。NX_KV_ASSETS 可覆盖,且**在调用时读、不在模块加载时快照**——
20
+ // 测试要拿一棵伪资产树验 groups.json 的降级 / schema 错误,绝不能真去改仓库里的 assets/。
21
+ function assetsRoot() {
22
+ return process.env.NX_KV_ASSETS ? resolve(process.env.NX_KV_ASSETS) : DEFAULT_ASSETS_DIR;
23
+ }
14
24
 
15
25
  // 默认安装到 Claude Code 的用户级 skills 目录
16
26
  export const DEFAULT_SKILLS_DIR = join(homedir(), '.claude', 'skills');
17
27
 
28
+ // 内置主 skill 的规范名 = 包名(A03 §二:用户不必记两个名字)。
29
+ export const DEFAULT_SKILL_NAME = 'nx-kv';
30
+
18
31
  async function pathExists(p) {
19
32
  try {
20
33
  await fsp.access(p);
@@ -24,19 +37,121 @@ async function pathExists(p) {
24
37
  }
25
38
  }
26
39
 
40
+ // ─── B04:多 skill 的分组(assets/groups.json) ──────────────────────
41
+ //
42
+ // 事实源只有一个:`assets/<name>/SKILL.md`。`groups.json` 只是「便捷别名表」——
43
+ // 它缺失或损坏时降级成「每个 assets/<x> 各成一组」,用户照样能装,只是没了 --group。
44
+ // 但 **schema 错**必须显式报错:那是作者/打包事故,静默吞掉只会让排查抓瞎。
45
+
46
+ const GROUPS_REL = 'groups.json';
47
+
48
+ // 包内实际存在的 skill:靠 assets/<x>/SKILL.md 判定
49
+ async function assetSkillDirs() {
50
+ const entries = await fsp.readdir(assetsRoot(), { withFileTypes: true }).catch(() => []);
51
+ const out = [];
52
+ for (const e of entries) {
53
+ if (!e.isDirectory()) continue;
54
+ if (await pathExists(join(assetsRoot(), e.name, 'SKILL.md'))) out.push(e.name);
55
+ }
56
+ return out.sort();
57
+ }
58
+
59
+ function degradeGroups(names) {
60
+ const groups = {};
61
+ for (const n of names) {
62
+ groups[n] = { skills: [n], summary: '(groups.json 缺失或损坏,按目录扫描兜底)' };
63
+ }
64
+ return groups;
65
+ }
66
+
67
+ export async function loadGroups() {
68
+ const raw = await fsp.readFile(join(assetsRoot(), GROUPS_REL), 'utf8').catch(() => null);
69
+ if (raw === null) return { groups: degradeGroups(await assetSkillDirs()), source: 'assets-dirs' };
70
+
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ } catch {
75
+ process.stderr.write('[nx-kv] assets/groups.json 不是合法 JSON,已降级为目录扫描\n');
76
+ return { groups: degradeGroups(await assetSkillDirs()), source: 'assets-dirs' };
77
+ }
78
+
79
+ const g = parsed && parsed.groups;
80
+ if (!g || typeof g !== 'object' || Array.isArray(g)) {
81
+ throw badInput('assets/groups.json 缺少 groups 对象(schema 错)');
82
+ }
83
+ const groups = {};
84
+ for (const [key, val] of Object.entries(g)) {
85
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(key)) throw badInput(`assets/groups.json 的 group 名非法: ${key}`);
86
+ if (!val || !Array.isArray(val.skills) || !val.skills.length) {
87
+ throw badInput(`assets/groups.json 的 group "${key}" 缺少非空 skills 数组(schema 错)`);
88
+ }
89
+ for (const s of val.skills) assertSafeName(s, 'group 里的 skill 名');
90
+ // 同一组里重复的 skill 名去重,避免重复三态与重复计数
91
+ groups[key] = { skills: [...new Set(val.skills)], summary: String(val.summary || '') };
92
+ }
93
+ if (!Object.keys(groups).length) throw badInput('assets/groups.json 里没有任何 group(schema 错)');
94
+ return { groups, source: 'manifest' };
95
+ }
96
+
97
+ // 默认 group 名 = 包名(B04「默认 install 的约定」)。
98
+ // 全局安装时 cwd 是用户目录,读 cwd 永远匹配不上,故优先读**本包自己的** package.json;
99
+ // 读不到就 null(不崩)。
100
+ let pkgNameCache;
101
+ async function packageName() {
102
+ if (pkgNameCache !== undefined) return pkgNameCache;
103
+ const own = fileURLToPath(new URL('../../../package.json', import.meta.url));
104
+ for (const p of [own, join(process.cwd(), 'package.json')]) {
105
+ try {
106
+ const name = JSON.parse(await fsp.readFile(p, 'utf8')).name;
107
+ if (name) { pkgNameCache = String(name); return pkgNameCache; }
108
+ } catch { /* 换下一个 */ }
109
+ }
110
+ pkgNameCache = null;
111
+ return pkgNameCache;
112
+ }
113
+
114
+ async function resolveGroup(key) {
115
+ const k = String(key ?? '').trim();
116
+ if (!k) {
117
+ throw badInput('--group 需要一个值(可用: ' + (Object.keys((await loadGroups()).groups).join(', ') || '(无)') + ')');
118
+ }
119
+ if (k.startsWith('-')) throw badInput(`--group 的值不能以 - 开头: ${k}`);
120
+ const { groups } = await loadGroups();
121
+ const g = groups[k];
122
+ if (!g) throw badInput(`未知 group: ${k}(可用: ${Object.keys(groups).join(', ') || '(无)'})`);
123
+ return g;
124
+ }
125
+
126
+ // `skill list` 的数据:可装的 skill + 默认装哪个 + 有哪些 group + 数据来源
127
+ export async function groupsInfo() {
128
+ const { groups, source } = await loadGroups();
129
+ const ids = Object.keys(groups);
130
+ const def = await packageName();
131
+ return {
132
+ skills: await assetSkillDirs(),
133
+ // 标记只认「与包名同名」的那一项;无匹配就不显示标记(不崩)
134
+ defaultGroup: def && groups[def] ? def : null,
135
+ groups: ids,
136
+ summaries: Object.fromEntries(ids.map((id) => [id, groups[id].summary])),
137
+ groupSkills: Object.fromEntries(ids.map((id) => [id, groups[id].skills])),
138
+ source,
139
+ };
140
+ }
141
+
27
142
  export function assetsDir() {
28
- return ASSETS_DIR;
143
+ return assetsRoot();
29
144
  }
30
145
 
31
146
  // 列出包内自带的所有 skill(含文件数与描述)
32
147
  export async function listBundledSkills() {
33
148
  const out = [];
34
- const entries = await fsp.readdir(ASSETS_DIR, { withFileTypes: true }).catch(() => []);
149
+ const entries = await fsp.readdir(assetsRoot(), { withFileTypes: true }).catch(() => []);
35
150
  for (const e of entries) {
36
151
  if (!e.isDirectory()) continue;
37
- const dir = join(ASSETS_DIR, e.name);
152
+ const dir = join(assetsRoot(), e.name);
38
153
  // 用不存在的路径当哨兵,diffTrees 会把包内文件全列为 only-central,等于列出文件清单
39
- const files = await diffTrees(dir, join(dir, '__nx_rh_missing__'));
154
+ const files = await diffTrees(dir, join(dir, '__nx_kv_missing__'));
40
155
  if (!files.length) continue;
41
156
 
42
157
  // 描述解析复用 core/frontmatter.js —— 这里原本抄了一份简化正则,
@@ -48,14 +163,13 @@ export async function listBundledSkills() {
48
163
  return out.sort((a, b) => a.name.localeCompare(b.name));
49
164
  }
50
165
 
51
- // 安装内置 skill 到目标 skills 目录(默认 ~/.claude/skills)
52
- // 返回 { status: 'ok' | 'conflict', ... }——已存在且内容不同是**业务结果**,
53
- // 需要用户决定是否覆盖,不是错误。
54
- export async function installBundledSkill({ name = 'nx-kv', to, force } = {}) {
55
- name = assertSafeName(name);
56
- const src = join(ASSETS_DIR, name);
166
+ // 安装**一个**内置 skill。返回值形状与历史逐字节兼容,只多一个 `group` 字段
167
+ // (B04 §实现要点:判别单/多 skill 必须用显式 `group` 字段,不做形状嗅探)。
168
+ async function installOne(name, { to, force } = {}) {
169
+ name = assertSafeName(String(name || '').trim());
170
+ const src = join(assetsRoot(), name);
57
171
  if (!(await pathExists(join(src, 'SKILL.md')))) {
58
- const available = (await listBundledSkills()).map((s) => s.name).join(', ') || '(无)';
172
+ const available = (await assetSkillDirs()).join(', ') || '(无)';
59
173
  throw notFound(`未找到内置 skill: ${name}(可用: ${available})`);
60
174
  }
61
175
 
@@ -64,17 +178,105 @@ export async function installBundledSkill({ name = 'nx-kv', to, force } = {}) {
64
178
  const files = await diffTrees(src, dst); // side: only-central=新增, both-differ=不同, only-project=多余
65
179
 
66
180
  if (!files.length) {
67
- return { status: 'ok', skipped: true, name, path: dst, files: 0 };
181
+ return { status: 'ok', skipped: true, name, path: dst, files: 0, group: null };
68
182
  }
69
183
 
70
184
  const exists = await pathExists(dst);
71
185
  if (exists && !force) {
72
- return { status: 'conflict', name, path: dst, files, count: files.length };
186
+ return { status: 'conflict', name, path: dst, files, count: files.length, group: null };
73
187
  }
74
188
 
75
189
  if (exists) await fsp.rm(dst, { recursive: true, force: true });
76
190
  await fsp.mkdir(targetRoot, { recursive: true });
77
191
  await fsp.cp(src, dst, { recursive: true, dereference: true, force: true });
78
192
 
79
- return { status: 'ok', installed: !exists, replaced: exists, name, path: dst, files: files.length };
193
+ return { status: 'ok', installed: !exists, replaced: exists, name, path: dst, files: files.length, group: null };
194
+ }
195
+
196
+ // 安装内置 skill 到目标 skills 目录(默认 nx-kv → ~/.claude/skills)。
197
+ // 返回 { status: 'ok' | 'conflict', ... }——已存在且内容不同是**业务结果**,
198
+ // 需要用户决定是否覆盖,不是错误。
199
+ //
200
+ // `group` 给定时装该组的**全部** skill,返回聚合形状 { status, group, skills:[…] };
201
+ // 不给则装单个,形状与历史一致。两者的判别靠显式 `group` 字段。
202
+ export async function installBundledSkill({ name = DEFAULT_SKILL_NAME, group, to, force } = {}) {
203
+ if (group !== undefined && group !== null && group !== '') {
204
+ const g = await resolveGroup(group);
205
+ const skills = [];
206
+ for (const s of g.skills) skills.push(await installOne(s, { to, force }));
207
+ return {
208
+ // 有一个冲突就是 conflict:调用方要么加 --force 要么逐个处理
209
+ status: skills.every((r) => r.status === 'ok') ? 'ok' : 'conflict',
210
+ group: String(group).trim(),
211
+ skills,
212
+ };
213
+ }
214
+ return installOne(name, { to, force });
215
+ }
216
+
217
+ // ─── skill get:把内置 skill 全文交给外部 agent(A03 §三) ───────────
218
+ // install 解决「本骨架的 agent 学得会用」,get 解决「外部 agent 一键拿全上下文」——
219
+ // 两者互补,不是二选一。get 永远给文档:目标端 conflict 也照常输出,
220
+ // 冲突信息放在 install 字段里让调用方自己决定要不要 --force。
221
+
222
+ // ref 解析(A03 §三):缺省 → SKILL.md;裸名 → references/<名>.md 再 <名>.md;
223
+ // 带分隔符 → 相对 assets/<name>/;含 .. 或绝对路径一律拒绝。
224
+ async function listRefs(dir) {
225
+ const out = [];
226
+ const entries = await fsp.readdir(join(dir, 'references'), { withFileTypes: true }).catch(() => []);
227
+ for (const e of entries) if (e.isFile() && e.name.endsWith('.md')) out.push(e.name.slice(0, -3));
228
+ return out.join(', ') || '(无)';
229
+ }
230
+
231
+ async function contentOne(name, { ref, to } = {}) {
232
+ name = assertSafeName(String(name || '').trim());
233
+ const dir = join(assetsRoot(), name);
234
+ if (!(await pathExists(join(dir, 'SKILL.md')))) {
235
+ const available = (await assetSkillDirs()).join(', ') || '(无)';
236
+ throw notFound(`未找到内置 skill: ${name}(可用: ${available})`);
237
+ }
238
+
239
+ let rel;
240
+ const raw = String(ref || '').trim();
241
+ if (!raw) {
242
+ rel = 'SKILL.md';
243
+ } else {
244
+ if (raw.split(/[\\/]+/).includes('..')) throw badInput(`ref 路径不允许包含 '..': ${raw}`);
245
+ if (/^([a-zA-Z]:|[\\/])/.test(raw)) throw badInput('ref 越界(必须是相对路径): ' + raw);
246
+ if (/[\\/]/.test(raw) || raw.startsWith('./')) {
247
+ rel = raw.replace(/^\.\//, '');
248
+ } else {
249
+ const bare = raw.endsWith('.md') ? raw : raw + '.md';
250
+ const inRefs = join('references', bare);
251
+ if (await pathExists(join(dir, inRefs))) rel = inRefs;
252
+ else if (await pathExists(join(dir, bare))) rel = bare;
253
+ else throw badInput(`未找到 ref: ${raw}(可用: ${await listRefs(dir)})`);
254
+ }
255
+ }
256
+
257
+ const content = await fsp.readFile(join(dir, rel), 'utf8').catch(() => null);
258
+ if (content === null) throw notFound(`skill ${name} 里没有文件: ${rel}`);
259
+
260
+ // get 的语义是「读」不是「修」:conflict 照常返回文档,不提供 --force
261
+ const install = await installOne(name, { to });
262
+ return {
263
+ skillName: name,
264
+ // 机器契约字段统一正斜杠,Windows 上不出现反斜杠
265
+ ref: rel.split('\\').join('/'),
266
+ content,
267
+ contentBytes: Buffer.byteLength(content, 'utf8'),
268
+ install,
269
+ };
270
+ }
271
+
272
+ // `group` 给定时把该组**每个** skill 的全文都取回来,返回 { group, skills:[…] };
273
+ // 不给则单个。判别同样用显式 `group` 字段。
274
+ export async function bundledSkillContent({ name = DEFAULT_SKILL_NAME, group, ref, to } = {}) {
275
+ if (group !== undefined && group !== null && group !== '') {
276
+ const g = await resolveGroup(group);
277
+ const skills = [];
278
+ for (const s of g.skills) skills.push(await contentOne(s, { ref, to }));
279
+ return { group: String(group).trim(), skills };
280
+ }
281
+ return contentOne(name, { ref, to });
80
282
  }