@morehao/dsh-skills-console 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,8 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
41
41
 
42
42
  ## 安装
43
43
 
44
+ 插件依赖里自带 `skills@1.7.4`,**不需要用户预装 Node / npm / npx**:CLI 由宿主自带的 Node 启动,装插件时随依赖一起落盘。安装技能仍需要**本机有 git**(CLI 会 `git clone` 仓库);首次装插件需要联网拉取依赖。
45
+
44
46
  在 Harness 里装本地 link:
45
47
 
46
48
  ```bash
@@ -55,7 +57,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
55
57
  name: '@morehao/dsh-skills-console'
56
58
  ```
57
59
 
58
- 客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。
60
+ 客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。本地 `link:` 开发时,先在仓库跑一次 `npm install`(装上 `skills`),否则 CLI 依赖层不会命中。
59
61
 
60
62
  ## 快速上手
61
63
 
@@ -66,7 +68,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
66
68
 
67
69
  ## 可靠性
68
70
 
69
- - **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json` 的增删改只经 `skills` CLI,插件不直接写、不克隆仓库、不代理来源。
71
+ - **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`)的增删改只经 `skills` CLI,插件不直接写;探测 / 更新检查会把仓库临时 clone 到 `os.tmpdir()`(只读、用完即删),结果绝不落进 canonical 或 lock。
70
72
  - **只动自己的东西**:只处理本插件创建的 `managed` 链接,以及已记账的 `managed-external`;`real` 目录和 `foreign-link` 永不改写,删除前体检直接中止。
71
73
  - **启停不改正文**:`mode` 只增删 DSH 侧链接,避免本地修改被下一次 `add` 覆盖。
72
74
  - **可回滚**:删除进回收站(7 天);更新前快照到 `backups/`,失败自动还原(接口另提供手动回滚)。
@@ -78,13 +80,29 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
78
80
  | 项 | 路径 | 归属 |
79
81
  | --- | --- | --- |
80
82
  | canonical 真身 | `~/.agents/skills` | `skills` CLI 独占 |
81
- | CLI lock | `~/.agents/.skill-lock.json` | `skills` CLI 独占(本插件只读) |
83
+ | CLI lock | `~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`) | `skills` CLI 独占(本插件只读) |
82
84
  | DSH 全局根 | `~/.dsh/skills` | 本插件写软链 |
83
85
  | 插件状态 | `$DSH_HOME/skills-console/state.json` | 本插件(`DSH_SKILLS_CONSOLE_HOME` 可覆盖) |
84
86
  | 备份 / 回收站 | `$DSH_HOME/skills-console/{backups,trash}` | 本插件 |
85
87
  | 事件日志 | `$DSH_HOME/skills-console/dsh-skills-console.log` | 本插件(JSONL,1MB 滚动) |
86
88
 
87
- 环境变量:`DSH_SKILLS_CONSOLE_HOME`、`DSH_SKILLS_CONSOLE_CLI`、`DSH_SKILLS_CONSOLE_NPX_SPEC`、`DSH_SKILLS_CONSOLE_TIMEOUT_MS`、`GITHUB_TOKEN` / `GH_TOKEN`。
89
+ ### 环境变量
90
+
91
+ | 变量 | 默认 | 作用 |
92
+ | --- | --- | --- |
93
+ | `DSH_SKILLS_CONSOLE_HOME` | `$DSH_HOME/skills-console` | 状态、备份、回收站与事件日志的根 |
94
+ | `DSH_SKILLS_CONSOLE_CLI` | 自动定位 | 覆盖 `skills` CLI(测试桩也走这里) |
95
+ | `DSH_SKILLS_CONSOLE_NPX_SPEC` | `skills` | 仅 legacy `npx` 兜底用;桌面端没有 npx,正常应显示 `dependency` |
96
+ | `DSH_SKILLS_CONSOLE_TIMEOUT_MS` | `120000` | 单次 CLI 调用超时 |
97
+ | `DSH_SKILLS_CONSOLE_GIT` | `auto` | 设为 `off` 关闭 git 通道,强制只走 GitHub REST |
98
+ | `DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS` | `60000` | git 单次操作(`ls-remote` / `clone`)超时 |
99
+ | `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时 |
100
+ | `XDG_STATE_HOME` | 未设置 | 设置后 CLI lock 移到 `$XDG_STATE_HOME/skills/.skill-lock.json`,插件跟随读取 |
101
+
102
+ 关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403。本插件默认让探测、更新检查与 git 兜底走系统 `git`(`ls-remote` + `clone --filter=blob:none --no-checkout`),不占 REST 配额、也不需要 token;git 不可用时才回退 REST,所以原本能用的环境不会因为这条路径变坏。限流且 git 也不可用时,错误文案会给出恢复时间,并提示设置 `GITHUB_TOKEN`。
103
+
104
+ > git 通道只在 `os.tmpdir()` 里读一份临时仓库,用完即删 —— 不写 canonical、不写 CLI lock,落盘始终只交给 `skills` CLI。
105
+
88
106
 
89
107
  ## 开发与发布
90
108
 
package/lib/core/cli.js CHANGED
@@ -40,10 +40,15 @@ function findInPath(command, envPath) {
40
40
  }
41
41
 
42
42
  /**
43
- * CLI 定位四层(设计 §8.2):显式覆盖 → 插件/宿主自带依赖 → PATH → npx。
43
+ * CLI 定位顺序:显式覆盖 → 本插件依赖 → 宿主 profile → PATH → npx(legacy)。
44
+ * 插件依赖排在 profile 前面:版本随插件锁死,不被 PATH 或别的插件带的旧版抢占。
44
45
  * 每一层都记来源,诊断页要展示"这次到底跑的哪一个"。
45
46
  */
46
- export function locateCli({ env = process.env, logger = () => {} } = {}) {
47
+ export function locateCli({
48
+ env = process.env,
49
+ logger = () => {},
50
+ resolvePackage = () => require.resolve('skills/package.json'),
51
+ } = {}) {
47
52
  const override = env.DSH_SKILLS_CONSOLE_CLI;
48
53
  if (override && override.trim()) {
49
54
  const parts = override.trim().split(/\s+/);
@@ -51,7 +56,20 @@ export function locateCli({ env = process.env, logger = () => {} } = {}) {
51
56
  return { command: parts[0], prefixArgs: parts.slice(1), source: 'env-override', jsonCapable: true };
52
57
  }
53
58
 
54
- // 宿主 profile 里的 skills 依赖(最常见的实际位置)。
59
+ // 插件自己的 runtime 依赖:装插件时由宿主 pnpm 一起装好,用户不需要 Node/npm/npx。
60
+ try {
61
+ const pkg = resolvePackage();
62
+ const bin = path.join(path.dirname(pkg), 'bin', 'cli.mjs');
63
+ if (fs.existsSync(bin)) {
64
+ return { command: process.execPath, prefixArgs: [bin], source: 'dependency', jsonCapable: true };
65
+ }
66
+ // 包在但入口丢了,说明安装不完整;交给后面的层兜底,但要留痕
67
+ logger('cli.dependency.bin-missing', { pkg, bin });
68
+ } catch {
69
+ // 没装在本插件可见范围内(例如 link: 开发仓库还没 npm install),继续往下走
70
+ }
71
+
72
+ // 宿主 profile 里的 skills 依赖(兼容旧插件 / 其他插件已经带进来的版本)。
55
73
  // 注意:GUI 启动的宿主进程常常拿不到 DSH_PROFILE_DIR,所以再按 DSH_HOME/profiles/* 兜一层探测。
56
74
  const profileDir = env.DSH_PROFILE_DIR;
57
75
  if (profileDir) {
@@ -74,16 +92,6 @@ export function locateCli({ env = process.env, logger = () => {} } = {}) {
74
92
  // 没有 profiles 目录就继续往下走
75
93
  }
76
94
 
77
- try {
78
- const pkg = require.resolve('skills/package.json');
79
- const bin = path.join(path.dirname(pkg), 'bin', 'cli.mjs');
80
- if (fs.existsSync(bin)) {
81
- return { command: process.execPath, prefixArgs: [bin], source: 'dependency', jsonCapable: true };
82
- }
83
- } catch {
84
- // 没有作为依赖装在本插件可见范围内,继续往下走
85
- }
86
-
87
95
  const onPath = findInPath('skills', env.PATH);
88
96
  if (onPath) return { command: onPath, prefixArgs: [], source: 'path', jsonCapable: true };
89
97
 
@@ -105,8 +113,13 @@ export function runCli(location, args, { timeoutMs = 120000, signal, env = proce
105
113
  stdio: ['ignore', 'pipe', 'pipe'],
106
114
  env: {
107
115
  ...env,
116
+ // 桌面宿主本身就是 Electron 的 Node 模式;显式补上,避免调用方传了裁剪过的 env
117
+ ...(process.versions.electron && location.command === process.execPath ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
108
118
  CI: '1',
109
119
  NO_COLOR: '1',
120
+ // CLI 的遥测只看这两个变量;CI=1 不会关闭,只会在上报里打 ci=1 标记
121
+ DO_NOT_TRACK: '1',
122
+ DISABLE_TELEMETRY: '1',
110
123
  TERM: 'dumb',
111
124
  GIT_TERMINAL_PROMPT: '0',
112
125
  GIT_HTTP_LOW_SPEED_LIMIT: '1',
@@ -151,12 +164,12 @@ export function runCli(location, args, { timeoutMs = 120000, signal, env = proce
151
164
  });
152
165
  }
153
166
 
154
- export function createCli({ env = process.env, logger = () => {} } = {}) {
167
+ export function createCli({ env = process.env, logger = () => {}, resolvePackage } = {}) {
155
168
  let cached = null;
156
169
  let capabilities = null;
157
170
 
158
171
  function location() {
159
- if (!cached) cached = locateCli({ env, logger });
172
+ if (!cached) cached = locateCli({ env, logger, resolvePackage });
160
173
  return cached;
161
174
  }
162
175
 
@@ -178,7 +191,16 @@ export function createCli({ env = process.env, logger = () => {} } = {}) {
178
191
  /** 能力位:`--json` 与 `--full-depth` 任一不支持都要在 UI 上禁用写操作(漏装比不可用更危险)。 */
179
192
  async capabilities({ force = false } = {}) {
180
193
  if (capabilities && !force) return capabilities;
181
- const loc = location();
194
+ let loc;
195
+ try {
196
+ loc = location();
197
+ } catch (error) {
198
+ if (!(error instanceof CliUnavailableError)) throw error;
199
+ // 不缓存:用户补好依赖 / PATH 后,下一次 /state 就能恢复,不必重启宿主;
200
+ // 同时清掉旧的成功缓存,避免 force 探测到 missing 后又回落到上一轮的 usable。
201
+ capabilities = null;
202
+ return { version: null, json: false, fullDepth: false, source: 'missing', usable: false };
203
+ }
182
204
  if (!loc.jsonCapable) {
183
205
  capabilities = { version: null, json: false, fullDepth: false, source: loc.source, usable: false };
184
206
  return capabilities;
@@ -0,0 +1,282 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * 只读 git 通道:`ls-remote` 拿默认分支与 HEAD,`clone --filter=blob:none --no-checkout` +
8
+ * `ls-tree` 拿整树。**不占 GitHub REST 配额**,也不需要 token / gh —— 这是「出口 IP 被
9
+ * 匿名配额打死」时唯一还通的路。
10
+ *
11
+ * 硬约束(见 AGENTS.md「数据边界」):
12
+ * - 只读:只在 `os.tmpdir()` 里建临时仓库,`finally` 必删;不碰 canonical、不碰 lock;
13
+ * - 不接受任意 URL:只认 `https://github.com/<owner>/<repo>.git`(由 `repoUrl` 从
14
+ * `parseSource` 的产物重建),拒绝以 `-` 开头的 ref(防 argv 注入成选项);
15
+ * - 零依赖:只用 node:child_process / node:fs / node:os / node:path。
16
+ */
17
+
18
+ const DEFAULT_TIMEOUT_MS = 60_000;
19
+ const VERSION_TIMEOUT_MS = 10_000;
20
+ const MAX_STDOUT = 8 * 1024 * 1024;
21
+ const MAX_STDERR = 64 * 1024;
22
+ const TMP_PREFIX = 'dsh-skills-git-';
23
+ const REPO_URL_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\.git$/;
24
+ const COMMIT_RE = /^[0-9a-f]{40}$/;
25
+ // 分支/tag 名:足够覆盖真实用法,同时排除以 `-` 开头(会被 git 当成选项)
26
+ const REF_RE = /^[\w][\w./-]{0,254}$/;
27
+
28
+ /** `kind` 供上层决定是否回退 REST:`git-missing` / `git-timeout` / `auth` / `clone-failed` / `invalid-url`。 */
29
+ export class GitError extends Error {
30
+ constructor(kind, message, detail) {
31
+ super(message);
32
+ this.name = 'GitError';
33
+ this.kind = kind;
34
+ this.detail = detail || null;
35
+ }
36
+ }
37
+
38
+ /** 从 `owner/repo`(`parseSource` 的产物)重建 URL,再做一次白名单断言。 */
39
+ export function repoUrl(source) {
40
+ const match = /^([\w.-]+)\/([\w.-]+)$/.exec(String(source || '').trim());
41
+ if (!match) throw new GitError('invalid-url', `不是 owner/repo 形态:${source}`);
42
+ const repo = match[2].replace(/\.git$/, '');
43
+ const url = `https://github.com/${match[1]}/${repo}.git`;
44
+ if (!REPO_URL_RE.test(url)) throw new GitError('invalid-url', `拒绝不安全的仓库地址:${url}`);
45
+ return url;
46
+ }
47
+
48
+ function assertUrl(url) {
49
+ if (typeof url !== 'string' || !REPO_URL_RE.test(url)) {
50
+ throw new GitError('invalid-url', `拒绝不安全的仓库地址:${url}`);
51
+ }
52
+ }
53
+
54
+ function assertRef(ref) {
55
+ if (typeof ref !== 'string' || !(REF_RE.test(ref) || COMMIT_RE.test(ref))) {
56
+ throw new GitError('invalid-url', `拒绝不安全的 ref:${ref}`);
57
+ }
58
+ }
59
+
60
+ function withDefaults(env) {
61
+ return {
62
+ ...env,
63
+ // 私有仓库 / 需要凭据时立刻失败,绝不卡在交互提示上
64
+ GIT_TERMINAL_PROMPT: '0',
65
+ GIT_HTTP_LOW_SPEED_LIMIT: '1',
66
+ GIT_HTTP_LOW_SPEED_TIME: '30',
67
+ LC_ALL: 'C',
68
+ };
69
+ }
70
+
71
+ /** 子进程封装:无 shell、argv 直传、硬超时 SIGKILL、stdout 上限。永不 reject。 */
72
+ function runGit(args, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env } = {}) {
73
+ return new Promise((resolve) => {
74
+ let settled = false;
75
+ const finish = (result) => {
76
+ if (settled) return;
77
+ settled = true;
78
+ resolve(result);
79
+ };
80
+ let child;
81
+ try {
82
+ child = spawn('git', args, {
83
+ cwd,
84
+ shell: false,
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ env: withDefaults(env),
87
+ });
88
+ } catch (error) {
89
+ finish({ spawnError: error, code: null, stdout: '', stderr: '', timedOut: false, args });
90
+ return;
91
+ }
92
+ let stdout = '';
93
+ let stderr = '';
94
+ let timedOut = false;
95
+ const timer = setTimeout(() => {
96
+ timedOut = true;
97
+ child.kill('SIGKILL');
98
+ // 孙进程(git-remote-https 之类)可能还握着管道:不等它们退出,直接判超时
99
+ child.stdout?.destroy();
100
+ child.stderr?.destroy();
101
+ finish({ code: null, stdout, stderr, timedOut: true, args });
102
+ }, timeoutMs);
103
+ child.stdout.on('data', (chunk) => {
104
+ if (stdout.length >= MAX_STDOUT) return;
105
+ stdout += chunk.toString('utf8').slice(0, MAX_STDOUT - stdout.length);
106
+ });
107
+ child.stderr.on('data', (chunk) => {
108
+ if (stderr.length < MAX_STDERR) stderr += chunk.toString('utf8');
109
+ });
110
+ child.on('error', (error) => {
111
+ clearTimeout(timer);
112
+ finish({ spawnError: error, code: null, stdout, stderr, timedOut, args });
113
+ });
114
+ child.on('close', (code) => {
115
+ clearTimeout(timer);
116
+ finish({ code, stdout, stderr, timedOut, args });
117
+ });
118
+ });
119
+ }
120
+
121
+ /** 退出码非 0 → 分类。`auth` 涵盖「私有仓库 / 凭据不对 / 仓库不存在」这几种 git 都只说一句话的情况。 */
122
+ function toGitError(result, fallbackMessage = 'git 失败') {
123
+ if (result.spawnError) {
124
+ return new GitError('git-missing', result.spawnError.message, { code: result.spawnError.code || null });
125
+ }
126
+ if (result.timedOut) {
127
+ return new GitError('git-timeout', `git ${result.args?.[0] || ''} 超时`, { args: result.args || [] });
128
+ }
129
+ const stderr = String(result.stderr || '').trim();
130
+ if (
131
+ /authentication failed|could not read username|terminal prompts disabled|permission denied|repository[^\n]*not found|invalid username or password|access denied/i.test(
132
+ stderr,
133
+ )
134
+ ) {
135
+ return new GitError('auth', stderr.slice(-400) || '认证失败', { code: result.code ?? null });
136
+ }
137
+ return new GitError('clone-failed', (stderr || fallbackMessage).slice(-400), { code: result.code ?? null });
138
+ }
139
+
140
+ /** `git --version` 探测。没有 git 的环境本来也装不了技能,回退 REST 即可。 */
141
+ export async function gitAvailable({ env = process.env, timeoutMs = VERSION_TIMEOUT_MS } = {}) {
142
+ const result = await runGit(['--version'], { env, timeoutMs });
143
+ if (result.spawnError || result.timedOut || result.code !== 0) return false;
144
+ return /git version \d+/.test(result.stdout);
145
+ }
146
+
147
+ /** `git ls-remote --symref <url> HEAD` → `{ branch, commit }`。零配额拿默认分支 + HEAD。 */
148
+ export async function lsRemote({ url, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
149
+ assertUrl(url);
150
+ const result = await runGit(['ls-remote', '--symref', url, 'HEAD'], { env, timeoutMs });
151
+ if (result.spawnError || result.timedOut || result.code !== 0) throw toGitError(result, 'ls-remote 失败');
152
+ let branch = null;
153
+ let commit = null;
154
+ for (const raw of result.stdout.split('\n')) {
155
+ const line = raw.trim();
156
+ if (!line) continue;
157
+ if (line.startsWith('ref:')) {
158
+ const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(line);
159
+ if (symref) branch = symref[1];
160
+ continue;
161
+ }
162
+ const head = /^([0-9a-f]{40})\s+HEAD$/.exec(line);
163
+ if (head) commit = head[1];
164
+ }
165
+ if (!commit) throw new GitError('clone-failed', 'ls-remote 没有返回 HEAD 指向的 commit');
166
+ return { branch, commit };
167
+ }
168
+
169
+ /** `git ls-tree -r -t -z` 的输出 → `[{ type, path, sha }]`,与 Trees API 同形。 */
170
+ export function parseTreeEntries(stdout) {
171
+ const entries = [];
172
+ for (const record of String(stdout || '').split('\0')) {
173
+ if (!record) continue;
174
+ // 默认格式:`<mode> <type> <sha>\t<path>`;路径里的空格/中文都靠 tab 定位,不会被切错
175
+ const tab = record.indexOf('\t');
176
+ if (tab < 0) continue;
177
+ const meta = record.slice(0, tab).split(' ');
178
+ const type = meta[1];
179
+ if (type !== 'blob' && type !== 'tree') continue;
180
+ entries.push({ type, path: record.slice(tab + 1), sha: meta[2] });
181
+ }
182
+ return entries;
183
+ }
184
+
185
+ /**
186
+ * `git clone --depth 1 --filter=blob:none --no-checkout` + `ls-tree` → `{ entries, rootSha, commit }`。
187
+ * 临时目录用完必删;容器型 `<ref>`(commit SHA)走 fetch,分支/tag 走 `--branch`。
188
+ * `tmpRoot` 只是给测试一个隔离的临时目录根,生产走 `os.tmpdir()`。
189
+ */
190
+ export async function lsTree({ url, ref = 'HEAD', env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, tmpRoot = os.tmpdir() } = {}) {
191
+ assertUrl(url);
192
+ assertRef(ref);
193
+ const isCommit = COMMIT_RE.test(ref);
194
+ fs.mkdirSync(tmpRoot, { recursive: true });
195
+ const dir = fs.mkdtempSync(path.join(tmpRoot, TMP_PREFIX));
196
+ const base = ['clone', '--depth', '1', '--no-checkout', '--quiet'];
197
+ const cloneArgs = [...base, '--filter=blob:none'];
198
+ if (!isCommit) cloneArgs.push('--branch', ref);
199
+ cloneArgs.push(url, dir);
200
+
201
+ try {
202
+ let cloned = await runGit(cloneArgs, { env, timeoutMs });
203
+ // 老 git(< 2.19)不认 --filter:去掉再试一次(慢一点,但能用)
204
+ if ((cloned.spawnError || cloned.timedOut || cloned.code !== 0) && /filter/i.test(cloned.stderr || '')) {
205
+ const retry = [...base];
206
+ if (!isCommit) retry.push('--branch', ref);
207
+ retry.push(url, dir);
208
+ cloned = await runGit(retry, { env, timeoutMs });
209
+ }
210
+ if (cloned.spawnError || cloned.timedOut || cloned.code !== 0) throw toGitError(cloned, 'clone 失败');
211
+
212
+ // HEAD 对应的 commit:分支/tag 克隆时是 HEAD,commit 形态直接用它自己
213
+ const revTarget = isCommit ? `${ref}^{commit}` : 'HEAD^{commit}';
214
+ let rev = await runGit(['-C', dir, 'rev-parse', '--verify', revTarget], { env, timeoutMs });
215
+ if (rev.code !== 0 && isCommit) {
216
+ // 默认分支的浅克隆里可能没有这个 commit:单独取一次
217
+ await runGit(['-C', dir, 'fetch', '--depth', '1', '--quiet', url, ref], { env, timeoutMs });
218
+ rev = await runGit(['-C', dir, 'rev-parse', '--verify', revTarget], { env, timeoutMs });
219
+ }
220
+ const commit = rev.code === 0 ? rev.stdout.trim() : '';
221
+ if (!COMMIT_RE.test(commit)) throw toGitError(rev, 'rev-parse 拿不到 commit');
222
+
223
+ const treeRev = await runGit(['-C', dir, 'rev-parse', '--verify', `${commit}^{tree}`], { env, timeoutMs });
224
+ const rootSha = treeRev.code === 0 ? treeRev.stdout.trim() : '';
225
+ if (!COMMIT_RE.test(rootSha)) throw toGitError(treeRev, 'rev-parse 拿不到根 tree');
226
+
227
+ const listed = await runGit(['-C', dir, 'ls-tree', '-r', '-t', '-z', commit], { env, timeoutMs });
228
+ if (listed.code !== 0) throw toGitError(listed, 'ls-tree 失败');
229
+ return { entries: parseTreeEntries(listed.stdout), rootSha, commit };
230
+ } finally {
231
+ if (dir.includes(TMP_PREFIX)) fs.rmSync(dir, { recursive: true, force: true });
232
+ }
233
+ }
234
+
235
+ /**
236
+ * 服务实例用的封装:进程内缓存 `git --version` 结论与 `(url, ref)` 的整树
237
+ * (一个仓库装多个技能时只 clone 一次),并统一读 `DSH_SKILLS_CONSOLE_GIT*`。
238
+ */
239
+ export function createGit({ env = process.env, logger = () => {} } = {}) {
240
+ const TREE_TTL_MS = 60_000;
241
+ const TREE_MAX = 20;
242
+ const trees = new Map();
243
+ let available = null;
244
+
245
+ const enabled = () => String(env.DSH_SKILLS_CONSOLE_GIT || '').trim().toLowerCase() !== 'off';
246
+ const timeoutMs = () => {
247
+ const value = Number(env.DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS);
248
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_MS;
249
+ };
250
+
251
+ async function isAvailable() {
252
+ if (!enabled()) return false;
253
+ if (available === null) available = await gitAvailable({ env, timeoutMs: Math.min(timeoutMs(), VERSION_TIMEOUT_MS) });
254
+ if (!available) logger('git.missing');
255
+ return available;
256
+ }
257
+
258
+ async function tree({ url, ref }) {
259
+ const key = `${url}@${ref}`;
260
+ const cached = trees.get(key);
261
+ if (cached && Date.now() - cached.at < TREE_TTL_MS) return cached.value;
262
+ const value = await lsTree({ url, ref, env, timeoutMs: timeoutMs() });
263
+ trees.set(key, { value, at: Date.now() });
264
+ for (const oldest of trees.keys()) {
265
+ if (trees.size <= TREE_MAX) break;
266
+ trees.delete(oldest);
267
+ }
268
+ return value;
269
+ }
270
+
271
+ return {
272
+ enabled,
273
+ isAvailable,
274
+ timeoutMs,
275
+ url: repoUrl,
276
+ lsRemote: (options = {}) => lsRemote({ ...options, env, timeoutMs: options.timeoutMs || timeoutMs() }),
277
+ tree,
278
+ clearCache() {
279
+ trees.clear();
280
+ },
281
+ };
282
+ }
package/lib/core/paths.js CHANGED
@@ -12,6 +12,10 @@ export function createPaths(env = process.env, overrides = {}) {
12
12
  const dshHome = overrides.dshHome ?? env.DSH_HOME ?? path.join(home, '.dsh');
13
13
  const stateDir =
14
14
  overrides.stateDir ?? env.DSH_SKILLS_CONSOLE_HOME ?? path.join(dshHome, 'skills-console');
15
+ // 与 skills CLI 的 getSkillLockPath 对齐:设了 XDG_STATE_HOME 时 lock 在 XDG 下
16
+ const lockFile = env.XDG_STATE_HOME
17
+ ? path.join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json')
18
+ : path.join(home, '.agents', '.skill-lock.json');
15
19
 
16
20
  return {
17
21
  home,
@@ -20,8 +24,8 @@ export function createPaths(env = process.env, overrides = {}) {
20
24
  stateDir,
21
25
  /** canonical 真身:CLI 唯一的落点(F1) */
22
26
  canonical: path.join(home, '.agents', 'skills'),
23
- /** CLI 全局 lock(F4) */
24
- lockFile: path.join(home, '.agents', '.skill-lock.json'),
27
+ /** CLI 全局 lock(F4);XDG_STATE_HOME 优先,与 CLI 一致 */
28
+ lockFile,
25
29
  /** DSH 原生 user-dsh 根(rank 400) */
26
30
  dshGlobalSkills: path.join(dshHome, 'skills'),
27
31
  stateFile: path.join(stateDir, 'state.json'),
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { StateReadOnlyError } from './state.js';
4
4
  import { contentHash } from './hash.js';
5
+ import { createGit } from './git.js';
5
6
  import { ensureLink, inspectTarget, removeLink } from './links.js';
6
7
  import { buildLedger, readLock, scanCanonical } from './inventory.js';
7
8
  import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
@@ -9,6 +10,7 @@ import {
9
10
  checkSkill,
10
11
  fetchDefaultBranch,
11
12
  fetchTree,
13
+ hashShape,
12
14
  listSkillsFromTree,
13
15
  parseSource,
14
16
  planToken,
@@ -31,6 +33,62 @@ function nowIso() {
31
33
  return new Date().toISOString();
32
34
  }
33
35
 
36
+ // skills CLI 对任意 GitHub 仓库走 git clone;没装 git 时它把错误打在 stdout(spinner)里
37
+ const GIT_MISSING_RE = /spawn git ENOENT|git: command not found|not recognized as an internal or external command/i;
38
+
39
+ /** 把 CLI 的 add 失败翻译成可执行错误;git 缺失时不要只给一句 spawn ENOENT。 */
40
+ function cliAddFailure(result, action) {
41
+ const detail = {
42
+ code: result.code,
43
+ stderr: (result.stderr || '').slice(-2000),
44
+ stdout: (result.stdout || '').slice(-2000),
45
+ };
46
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`;
47
+ if (GIT_MISSING_RE.test(output)) {
48
+ return new ServiceError(
49
+ 'error.skills.git-missing',
50
+ '安装/更新技能需要本机有 git(skills CLI 会 clone 仓库);请安装 git 后重试',
51
+ detail,
52
+ );
53
+ }
54
+ return new ServiceError('error.skills.cli-failed', `skills ${action} 失败`, detail);
55
+ }
56
+
57
+ /** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
58
+ function formatClock(ms) {
59
+ const at = new Date(ms);
60
+ const pad = (value) => String(value).padStart(2, '0');
61
+ return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
62
+ }
63
+
64
+ /**
65
+ * 上游失败 → 可执行错误(T1)。四类必须互不相同,否则用户只知道「读不到」,
66
+ * 不知道该等、该改来源,还是该查网络。
67
+ */
68
+ function upstreamError(source, failure) {
69
+ const status = failure?.status ?? null;
70
+ const resetAt = failure?.resetAt ?? null;
71
+ const detail = { source, status, resetAt, error: failure?.error ?? null };
72
+ if (failure?.rateLimited) {
73
+ const resume = resetAt ? `约 ${formatClock(resetAt)} 后自动恢复;` : '';
74
+ return new ServiceError(
75
+ 'error.skills.rate-limited',
76
+ `GitHub 接口已限流(匿名 60 次/小时,按出口 IP 共享)。${resume}设置 GITHUB_TOKEN 可提到 5000 次/小时`,
77
+ detail,
78
+ );
79
+ }
80
+ if (status === 404) {
81
+ return new ServiceError('error.skills.repo-not-found', `读不到 ${source}:仓库不存在或为私有`, detail);
82
+ }
83
+ if (status === 401 || status === 403) {
84
+ return new ServiceError('error.skills.repo-forbidden', `读取 ${source} 被拒绝(HTTP ${status})`, detail);
85
+ }
86
+ if (status) {
87
+ return new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:HTTP ${status}`, detail);
88
+ }
89
+ return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
90
+ }
91
+
34
92
  /** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
35
93
  function installedNamesFrom(stdout) {
36
94
  try {
@@ -141,6 +199,7 @@ function createQueue(logger) {
141
199
  export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
142
200
  const enqueue = createQueue(logger);
143
201
  const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
202
+ const git = createGit({ env, logger });
144
203
  // 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
145
204
  const probeCache = new Map();
146
205
  const PROBE_TTL_MS = 60_000;
@@ -154,6 +213,19 @@ export function createService({ paths, store, registry, cli, logger, env = proce
154
213
  if (store.readOnly) throw new StateReadOnlyError(store.problem);
155
214
  }
156
215
 
216
+ /** 所有会调 CLI 的写操作共用;缺失时给 400,不要把裸 CliUnavailableError 漏成 500。 */
217
+ async function requireUsableCli() {
218
+ const capabilities = await cli.capabilities();
219
+ if (!capabilities.usable) {
220
+ throw new ServiceError(
221
+ 'error.skills.cli-missing',
222
+ 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用',
223
+ capabilities,
224
+ );
225
+ }
226
+ return capabilities;
227
+ }
228
+
157
229
  function detectedTargets() {
158
230
  return registry.detected();
159
231
  }
@@ -203,44 +275,51 @@ export function createService({ paths, store, registry, cli, logger, env = proce
203
275
  return { created, skipped, failed };
204
276
  }
205
277
 
206
- /** 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。 */
278
+ /**
279
+ * 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。
280
+ * 返回 `{ branch, failure }`:`failure` 原样透出 `fetchDefaultBranch` 的结构化失败,
281
+ * 由 `readSnapshot` 决定报哪种错误(T1)。
282
+ */
207
283
  async function branchOf(subscription, { persist = false } = {}) {
208
- if (subscription.ref) return subscription.ref;
209
- let branch = null;
284
+ if (subscription.ref) return { branch: subscription.ref, failure: null };
285
+ let resolved;
210
286
  try {
211
- branch = await fetchDefaultBranch({
287
+ resolved = await fetchDefaultBranch({
212
288
  source: subscription.source,
213
289
  token: tokenOf,
214
290
  fetchImpl,
215
291
  });
216
- } catch {
217
- branch = null;
292
+ } catch (error) {
293
+ return { branch: null, failure: { status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) } };
218
294
  }
219
- if (!branch) return null;
295
+ const branch = resolved?.branch || null;
296
+ if (!branch) return { branch: null, failure: resolved || null };
220
297
  if (persist && !store.readOnly) {
221
298
  store.update((draft) => {
222
299
  const row = draft.subscriptions.find((item) => item.id === subscription.id);
223
300
  if (row && !row.ref) row.ref = branch;
224
301
  });
225
302
  }
226
- return branch;
303
+ return { branch, failure: null };
227
304
  }
228
305
 
229
306
  /**
230
- * 读一次订阅快照:只有网络与纯计算,**不写 store**。
307
+ * 读一次订阅快照:只有网络与纯计算,**不写 store**(`persistRef` 例外,只补订阅的 ref)。
231
308
  * 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
232
309
  * 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
310
+ *
311
+ * T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
233
312
  */
234
- async function readSnapshot(subscription, { force = false, withCommit = true } = {}) {
313
+ async function readSnapshot(subscription, { force = false, withCommit = true, persistRef = false } = {}) {
235
314
  const source = subscription.source;
236
315
  try {
237
- const branch = await branchOf(subscription);
316
+ const { branch, failure } = await branchOf(subscription, { persist: persistRef });
238
317
  if (!branch) {
239
- throw new ServiceError(
240
- 'error.skills.upstream-failed',
241
- `读不到 ${source} 的默认分支(仓库不存在、私有,或 GitHub 接口不可用/已限流)`,
242
- { source },
243
- );
318
+ if (failure?.rateLimited || failure?.status === 404) {
319
+ const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
320
+ if (viaGit) return viaGit;
321
+ }
322
+ throw upstreamError(source, failure);
244
323
  }
245
324
  const result = await fetchTree({
246
325
  source,
@@ -250,6 +329,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
250
329
  fetchImpl,
251
330
  });
252
331
  if (result.error) {
332
+ // 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
333
+ if (result.rateLimited) {
334
+ const viaGit = await gitSnapshot(subscription, { ref: branch });
335
+ if (viaGit) return viaGit;
336
+ throw upstreamError(source, {
337
+ status: result.status ?? null,
338
+ rateLimited: true,
339
+ resetAt: result.resetAt ?? null,
340
+ error: result.error,
341
+ });
342
+ }
253
343
  throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
254
344
  source,
255
345
  ref: branch,
@@ -277,6 +367,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
277
367
  return {
278
368
  branch,
279
369
  changed,
370
+ via: 'rest',
371
+ tree: result.tree,
372
+ rootSha: result.rootSha,
280
373
  snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
281
374
  };
282
375
  } catch (error) {
@@ -287,8 +380,162 @@ export function createService({ paths, store, registry, cli, logger, env = proce
287
380
  }
288
381
  }
289
382
 
290
- function recordLink(name, target, linkPath, mode, created) {
291
- if (store.readOnly) return;
383
+ /**
384
+ * T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
385
+ * 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
386
+ */
387
+ async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
388
+ const source = subscription.source;
389
+ const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
390
+ if (!git.enabled()) return null;
391
+ const startedAt = Date.now();
392
+ let url;
393
+ try {
394
+ url = git.url(source);
395
+ } catch (error) {
396
+ logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
397
+ return null;
398
+ }
399
+ if (!(await git.isAvailable())) {
400
+ logger('git.fallback', { source, kind: 'git-missing', purpose });
401
+ return null;
402
+ }
403
+ try {
404
+ let branch = ref || null;
405
+ if (!branch) {
406
+ const head = await git.lsRemote({ url });
407
+ branch = head.branch || 'main';
408
+ }
409
+ const tree = await git.tree({ url, ref: branch });
410
+ const entries = listSkillsFromTree({
411
+ tree: tree.entries,
412
+ rootSha: tree.rootSha,
413
+ subpath: subscription.subpath,
414
+ repoName: source.split('/').pop(),
415
+ });
416
+ const changed =
417
+ !subscription.snapshot ||
418
+ subscription.snapshot.rootSha !== tree.rootSha ||
419
+ JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
420
+ logger(event, {
421
+ source,
422
+ ref: branch,
423
+ commit: tree.commit,
424
+ entries: entries.length,
425
+ changed,
426
+ ms: Date.now() - startedAt,
427
+ });
428
+ return {
429
+ branch,
430
+ changed,
431
+ via: 'git',
432
+ tree: tree.entries,
433
+ rootSha: tree.rootSha,
434
+ snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
435
+ };
436
+ } catch (error) {
437
+ logger('git.fallback', {
438
+ source,
439
+ kind: error.kind || 'clone-failed',
440
+ message: error.message,
441
+ purpose,
442
+ ms: Date.now() - startedAt,
443
+ });
444
+ return null;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
450
+ * 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
451
+ */
452
+ async function remoteCommitUnchanged(subscription) {
453
+ const commit = subscription.snapshot?.commit;
454
+ if (!commit || !git.enabled()) return false;
455
+ if (!(await git.isAvailable())) return false;
456
+ try {
457
+ const head = await git.lsRemote({ url: git.url(subscription.source) });
458
+ if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
459
+ return head.commit === commit;
460
+ } catch (error) {
461
+ logger('git.fallback', {
462
+ source: subscription.source,
463
+ kind: error.kind || 'clone-failed',
464
+ purpose: 'refresh',
465
+ });
466
+ return false;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
472
+ * 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
473
+ * 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
474
+ */
475
+ async function probeViaGit({ source, ref, subpath }) {
476
+ const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
477
+ if (!result) return null;
478
+ return { branch: result.branch, via: 'git', snapshot: result.snapshot };
479
+ }
480
+
481
+ /**
482
+ * T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
483
+ * 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
484
+ * `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
485
+ *
486
+ * best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
487
+ */
488
+ async function backfillBaselines({ names, source, lock, ref }) {
489
+ if (!git.enabled() || names.length === 0) return [];
490
+ const targets = [];
491
+ for (const name of names) {
492
+ const lockEntry = lock.skills?.[name];
493
+ if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
494
+ const install = store.findInstall(name);
495
+ if (!install || install.treeSha) continue;
496
+ targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
497
+ }
498
+ if (targets.length === 0) return [];
499
+ if (!(await git.isAvailable())) {
500
+ logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
501
+ return [];
502
+ }
503
+ const startedAt = Date.now();
504
+ try {
505
+ const url = git.url(source);
506
+ let useRef = ref || null;
507
+ if (!useRef) useRef = (await git.lsRemote({ url })).commit;
508
+ // 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
509
+ const tree = await git.tree({ url, ref: useRef });
510
+ const updated = [];
511
+ for (const target of targets) {
512
+ const treeSha = target.skillPath
513
+ ? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
514
+ : null;
515
+ if (!treeSha) {
516
+ logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
517
+ continue;
518
+ }
519
+ store.update((draft) => {
520
+ const row = draft.installs.find((item) => item.name === target.name);
521
+ if (row && !row.treeSha) row.treeSha = treeSha;
522
+ });
523
+ updated.push({ name: target.name, treeSha });
524
+ }
525
+ if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
526
+ return updated;
527
+ } catch (error) {
528
+ logger('git.error', {
529
+ source,
530
+ kind: error.kind || 'clone-failed',
531
+ message: error.message,
532
+ purpose: 'baseline',
533
+ });
534
+ return [];
535
+ }
536
+ }
537
+
538
+ function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
292
539
  const existing = state().links.find((item) => item.skill === name && item.target === target.target);
293
540
  if (existing) return;
294
541
  if (!created) return;
@@ -369,6 +616,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
369
616
  skillPath: record.skillPath,
370
617
  ref: record.ref,
371
618
  commit: null,
619
+ treeSha: null,
372
620
  installedHash: record.installedHash,
373
621
  installedAt: nowIso(),
374
622
  updatedAt: nowIso(),
@@ -617,10 +865,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
617
865
 
618
866
  async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
619
867
  assertWritable();
620
- const capabilities = await cli.capabilities();
621
- if (!capabilities.usable) {
622
- throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
623
- }
868
+ await requireUsableCli();
624
869
  // 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
625
870
  const requested = (skills || []).filter(Boolean);
626
871
 
@@ -639,12 +884,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
639
884
 
640
885
  const result = await cli.add({ source: sourceArg, skills: planned });
641
886
  if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
642
- if (result.code !== 0) {
643
- throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
644
- code: result.code,
645
- stderr: result.stderr.slice(-2000),
646
- });
647
- }
887
+ if (result.code !== 0) throw cliAddFailure(result, 'add');
648
888
  // 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
649
889
  const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
650
890
  if (names.length === 0) {
@@ -667,12 +907,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
667
907
  warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
668
908
  }
669
909
  const expected = snapshot?.entries?.find((entry) => entry.name === name);
670
- if (expected?.treeSha && lockEntry?.skillFolderHash && lockEntry.skillFolderHash !== expected.treeSha) {
910
+ const lockHash = lockEntry?.skillFolderHash || null;
911
+ const shape = hashShape(lockHash);
912
+ // 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
913
+ // (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
914
+ if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
671
915
  warnings.push({
672
916
  name,
673
917
  warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
674
918
  expected: expected.treeSha,
675
- actual: lockEntry.skillFolderHash,
919
+ actual: lockHash,
676
920
  });
677
921
  }
678
922
  const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
@@ -682,6 +926,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
682
926
  skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
683
927
  ref: pin,
684
928
  commit: snapshot?.commit || null,
929
+ // 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
930
+ treeSha: expected?.treeSha || null,
685
931
  installedHash,
686
932
  installedAt: nowIso(),
687
933
  updatedAt: nowIso(),
@@ -694,7 +940,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
694
940
  if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
695
941
  else draft.installs.push(record);
696
942
  });
697
- installed.push({ name, lockHash: lockEntry?.skillFolderHash || null, installedHash });
943
+ installed.push({ name, lockHash, lockHashShape: shape, installedHash });
698
944
  }
699
945
 
700
946
  const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
@@ -706,6 +952,14 @@ export function createService({ paths, store, registry, cli, logger, env = proce
706
952
  linkResult.failed.push(...one.failed);
707
953
  }
708
954
 
955
+ // T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
956
+ // 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
957
+ const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
958
+ for (const item of installed) {
959
+ const filled = baselines.find((row) => row.name === item.name);
960
+ if (filled) item.treeSha = filled.treeSha;
961
+ }
962
+
709
963
  logger('install', { source: sourceArg, skills: names, installed: installed.length });
710
964
  return {
711
965
  ok: true,
@@ -719,6 +973,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
719
973
 
720
974
  async remove({ name }) {
721
975
  assertWritable();
976
+ await requireUsableCli(); // 快照与体检之前就拒绝,避免留下孤儿 trash
722
977
  return enqueue(`remove:${name}`, async () => {
723
978
  const canonicalPath = path.join(paths.canonical, name);
724
979
  if (!fs.existsSync(canonicalPath)) {
@@ -793,19 +1048,30 @@ export function createService({ paths, store, registry, cli, logger, env = proce
793
1048
  if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
794
1049
  return cached.pending || cached.value;
795
1050
  }
796
- const pending = readSnapshot(
797
- {
798
- id: null,
1051
+ const pending = (async () => {
1052
+ // 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
1053
+ const viaGit = await probeViaGit({
799
1054
  source: canonicalSource,
800
1055
  ref: requestedRef,
801
1056
  subpath: requestedSubpath,
802
- snapshot: null,
803
- },
804
- { force: true, withCommit: false },
805
- ).then(async (probe) => {
1057
+ });
1058
+ const probe =
1059
+ viaGit ||
1060
+ (await readSnapshot(
1061
+ {
1062
+ id: null,
1063
+ source: canonicalSource,
1064
+ ref: requestedRef,
1065
+ subpath: requestedSubpath,
1066
+ snapshot: null,
1067
+ },
1068
+ { force: true, withCommit: false },
1069
+ ));
1070
+ // 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
1071
+ const enrichRef = probe.snapshot?.commit || probe.branch;
806
1072
  const enriched = await enrichSkillEntries({
807
1073
  source: canonicalSource,
808
- ref: probe.branch,
1074
+ ref: enrichRef,
809
1075
  entries: probe.snapshot.entries,
810
1076
  token: tokenOf,
811
1077
  fetchImpl,
@@ -817,10 +1083,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
817
1083
  ok: true,
818
1084
  source: canonicalSource,
819
1085
  ref: probe.branch,
1086
+ via: probe.via || 'rest',
820
1087
  skills,
821
1088
  skipped: enriched.length - skills.length,
822
1089
  };
823
- });
1090
+ })();
824
1091
  probeCache.set(key, { pending, at: Date.now() });
825
1092
  try {
826
1093
  const value = await pending;
@@ -930,6 +1197,22 @@ export function createService({ paths, store, registry, cli, logger, env = proce
930
1197
  assertWritable();
931
1198
  const subscription = state().subscriptions.find((item) => item.id === id);
932
1199
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
1200
+ // T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
1201
+ if (await remoteCommitUnchanged(subscription)) {
1202
+ const at = nowIso();
1203
+ store.update((draft) => {
1204
+ const row = draft.subscriptions.find((item) => item.id === id);
1205
+ if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
1206
+ });
1207
+ logger('subscription.refresh', { id, notModified: true, via: 'git' });
1208
+ return {
1209
+ ok: true,
1210
+ changed: false,
1211
+ notModified: true,
1212
+ via: 'git',
1213
+ subscription: state().subscriptions.find((item) => item.id === id),
1214
+ };
1215
+ }
933
1216
  let probe;
934
1217
  try {
935
1218
  probe = await readSnapshot(subscription, { force });
@@ -961,6 +1244,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
961
1244
  ok: true,
962
1245
  changed: !probe.notModified && probe.changed,
963
1246
  ...(probe.notModified ? { notModified: true } : {}),
1247
+ via: probe.via || 'rest',
964
1248
  subscription: state().subscriptions.find((item) => item.id === id),
965
1249
  };
966
1250
  },
@@ -983,22 +1267,24 @@ export function createService({ paths, store, registry, cli, logger, env = proce
983
1267
 
984
1268
  const results = [];
985
1269
  for (const { subscription, installs: groupInstalls } of groups.values()) {
986
- // 没写 ref 的订阅(旧版本/接管建的)按仓库默认分支检查,别硬套 main
987
- const ref = (await branchOf(subscription, { persist: true })) || 'main';
988
- const tree = await fetchTree({
989
- source: subscription.source,
990
- ref,
991
- etag: subscription.snapshot?.etag || null,
992
- token: tokenOf,
993
- fetchImpl,
994
- });
995
- if (tree.error) {
1270
+ // T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
1271
+ // withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
1272
+ let probe;
1273
+ try {
1274
+ probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
1275
+ } catch (error) {
996
1276
  for (const install of groupInstalls) {
997
- results.push({ name: install.name, status: 'unknown', detail: tree.error, checkedAt: nowIso() });
1277
+ results.push({
1278
+ name: install.name,
1279
+ status: 'unknown',
1280
+ detail: error.message,
1281
+ code: error.code || null,
1282
+ checkedAt: nowIso(),
1283
+ });
998
1284
  }
999
1285
  continue;
1000
1286
  }
1001
- if (tree.notModified) {
1287
+ if (probe.notModified) {
1002
1288
  for (const install of groupInstalls) {
1003
1289
  const previousCheck = state().checks?.[install.name];
1004
1290
  results.push({
@@ -1012,16 +1298,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1012
1298
  }
1013
1299
  for (const install of groupInstalls) {
1014
1300
  const skillPath = lock.skills?.[install.name]?.skillPath || install.skillPath;
1015
- const entry = tree.tree.some((item) => item.type === 'blob' && item.path === skillPath)
1301
+ const entry = probe.tree.some((item) => item.type === 'blob' && item.path === skillPath)
1016
1302
  ? { skillPath }
1017
1303
  : null;
1018
1304
  const verdict = checkSkill({
1019
1305
  lockHash: lock.skills?.[install.name]?.skillFolderHash || null,
1306
+ recordedTreeSha: install.treeSha ?? null,
1020
1307
  snapshotEntry: entry,
1021
- tree: tree.tree,
1022
- rootSha: tree.rootSha,
1308
+ tree: probe.tree,
1309
+ rootSha: probe.rootSha,
1023
1310
  });
1024
- results.push({ name: install.name, ...verdict, checkedAt: nowIso() });
1311
+ results.push({ name: install.name, ...verdict, via: probe.via, checkedAt: nowIso() });
1025
1312
  }
1026
1313
  }
1027
1314
 
@@ -1049,20 +1336,15 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1049
1336
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
1050
1337
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
1051
1338
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
1052
- const ref = (await branchOf(subscription)) || 'main';
1053
- const tree = await fetchTree({
1054
- source: subscription.source,
1055
- ref,
1056
- token: tokenOf,
1057
- fetchImpl,
1058
- });
1059
- if (tree.error) throw new ServiceError('error.skills.upstream-failed', tree.error);
1339
+ // 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
1340
+ const probe = await readSnapshot(subscription, { force: true });
1341
+ const ref = probe.branch;
1060
1342
  const lock = readLock(paths);
1061
1343
  const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
1062
- const entry = tree.tree.find((item) => item.type === 'blob' && item.path === skillPath);
1344
+ const entry = probe.tree.find((item) => item.type === 'blob' && item.path === skillPath);
1063
1345
  if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
1064
- const targetTreeSha = skillTreeSha({ tree: tree.tree, skillPath, rootSha: tree.rootSha });
1065
- const targetCommit = await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
1346
+ const targetTreeSha = skillTreeSha({ tree: probe.tree, skillPath, rootSha: probe.rootSha });
1347
+ const targetCommit = probe.snapshot?.commit ?? null;
1066
1348
  const token = planToken({
1067
1349
  subscriptionId: subscription.id,
1068
1350
  from: install.commit || null,
@@ -1086,6 +1368,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1086
1368
 
1087
1369
  async applyUpdate({ name, token, overwrite = false }) {
1088
1370
  assertWritable();
1371
+ await requireUsableCli();
1089
1372
  return enqueue(`apply:${name}`, async () => {
1090
1373
  const plan = await this.planUpdate({ name });
1091
1374
  if (token && token !== plan.token) {
@@ -1116,12 +1399,21 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1116
1399
 
1117
1400
  if (result.code !== 0 || !present || !verified) {
1118
1401
  if (backup) restoreBackup({ backup, toDir: canonicalPath });
1402
+ const failure = result.code !== 0 ? cliAddFailure(result, 'add') : null;
1403
+ const gitMissing = failure?.code === 'error.skills.git-missing';
1119
1404
  logger('updates.apply.failed', { name, code: result.code, verified, present });
1405
+ const suffix = backup ? ';已回滚到上一版' : ';没有可回滚的备份';
1120
1406
  return {
1121
1407
  ok: false,
1122
- code: 'error.skills.update-failed',
1123
- message: backup ? '更新失败,已回滚到上一版' : '更新失败,没有可回滚的备份',
1124
- detail: { cliCode: result.code, verified, present, stderr: result.stderr.slice(-2000) },
1408
+ code: gitMissing ? failure.code : 'error.skills.update-failed',
1409
+ message: gitMissing ? `${failure.message}${suffix}` : `更新失败${suffix}`,
1410
+ detail: {
1411
+ cliCode: result.code,
1412
+ verified,
1413
+ present,
1414
+ stderr: (result.stderr || '').slice(-2000),
1415
+ ...(failure?.detail || {}),
1416
+ },
1125
1417
  backup: backup?.key || null,
1126
1418
  };
1127
1419
  }
@@ -1131,9 +1423,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1131
1423
  const row = draft.installs.find((item) => item.name === name);
1132
1424
  if (!row) return;
1133
1425
  row.commit = plan.toCommit;
1426
+ // 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
1427
+ row.treeSha = plan.targetTreeSha || row.treeSha || null;
1134
1428
  row.installedHash = installedHash;
1135
1429
  row.updatedAt = nowIso();
1136
- row.backup = backup ? { key: backup.key, hash: install.installedHash || null } : row.backup;
1430
+ row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
1137
1431
  draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
1138
1432
  });
1139
1433
 
@@ -1160,6 +1454,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1160
1454
  row.installedHash = installedHash;
1161
1455
  row.updatedAt = nowIso();
1162
1456
  row.commit = null;
1457
+ // 回滚回去的是备份那一刻的内容,tree 基线也得跟着退回去(不知道就置空,绝不沿用新版)
1458
+ row.treeSha = install.backup.treeSha ?? null;
1163
1459
  draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
1164
1460
  });
1165
1461
  logger('updates.rollback', { name, backup: install.backup.key });
@@ -25,6 +25,27 @@ export function githubToken(env = process.env) {
25
25
  return env.GITHUB_TOKEN || env.GH_TOKEN || null;
26
26
  }
27
27
 
28
+ /**
29
+ * 限流相关响应头。GitHub 对匿名请求按**出口 IP** 记 60 次/小时,403 时这三个头是
30
+ * 用户唯一能拿到的可执行信息(还剩多少、什么时候恢复、要不要等)。
31
+ */
32
+ function rateLimitOf(response) {
33
+ const read = (name) => {
34
+ const value = response?.headers?.get?.(name);
35
+ return value === null || value === undefined || value === '' ? null : String(value);
36
+ };
37
+ const reset = read('x-ratelimit-reset');
38
+ const retryAfter = read('retry-after');
39
+ const resetAt = reset && /^\d+$/.test(reset) ? Number(reset) * 1000 : null;
40
+ return { remaining: read('x-ratelimit-remaining'), resetAt, retryAfter };
41
+ }
42
+
43
+ /** 403 + 剩余 0,或 429 —— 与上游判定一致(blob.ts:147-152)。 */
44
+ export function isRateLimited(status, rateLimit) {
45
+ if (status === 429) return true;
46
+ return status === 403 && rateLimit?.remaining === '0';
47
+ }
48
+
28
49
  async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
29
50
  const headers = {
30
51
  accept: 'application/vnd.github+json',
@@ -34,13 +55,14 @@ async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
34
55
  if (token) headers.authorization = `Bearer ${token}`;
35
56
  if (etag) headers['if-none-match'] = etag;
36
57
  const response = await fetchImpl(url, { headers });
37
- if (response.status === 304) return { status: 304, notModified: true, etag };
58
+ const rateLimit = rateLimitOf(response);
59
+ if (response.status === 304) return { status: 304, notModified: true, etag, rateLimit };
38
60
  if (!response.ok) {
39
61
  const text = await response.text().catch(() => '');
40
- return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}` };
62
+ return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}`, rateLimit };
41
63
  }
42
64
  const body = await response.json();
43
- return { status: response.status, body, etag: response.headers.get('etag') };
65
+ return { status: response.status, body, etag: response.headers.get('etag'), rateLimit };
44
66
  }
45
67
 
46
68
  /**
@@ -53,7 +75,15 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
53
75
  const url = `${API}/repos/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
54
76
  const result = await callJson(url, { token, etag, fetchImpl });
55
77
  if (result.notModified) return { notModified: true, etag: result.etag };
56
- if (result.error) return { error: result.error, status: result.status };
78
+ if (result.error) {
79
+ return {
80
+ error: result.error,
81
+ status: result.status,
82
+ rateLimited: isRateLimited(result.status, result.rateLimit),
83
+ resetAt: result.rateLimit?.resetAt ?? null,
84
+ rateLimit: result.rateLimit,
85
+ };
86
+ }
57
87
  const tree = Array.isArray(result.body?.tree) ? result.body.tree : [];
58
88
  return {
59
89
  tree,
@@ -67,13 +97,28 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
67
97
  /**
68
98
  * 仓库默认分支。`ref` 缺省时以它为准:不少技能仓库默认是 `master`,
69
99
  * 硬套 `main` 只会得到 404(例如 alchaincyf/huashu-design)。
100
+ *
101
+ * 返回结构化结果而不是 `string | null`:调用方要靠 `status` / `rateLimited` / `resetAt`
102
+ * 把「仓库不存在」「被限流」「网络不通」拆成三句不同的话(T1)。
70
103
  */
71
104
  export async function fetchDefaultBranch({ source, token, fetchImpl } = {}) {
72
105
  const parsed = parseSource(source);
73
- if (!parsed) return null;
74
- const result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}`, { token, fetchImpl });
75
- if (result.error || !result.body?.default_branch) return null;
76
- return result.body.default_branch;
106
+ if (!parsed) {
107
+ return { branch: null, status: null, rateLimited: false, resetAt: null, error: 'invalid-source' };
108
+ }
109
+ let result;
110
+ try {
111
+ result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}`, { token, fetchImpl });
112
+ } catch (error) {
113
+ return { branch: null, status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) };
114
+ }
115
+ const rateLimit = result.rateLimit || {};
116
+ const rateLimited = isRateLimited(result.status, rateLimit);
117
+ const base = { status: result.status ?? null, rateLimited, resetAt: rateLimit.resetAt ?? null };
118
+ if (result.error) return { ...base, branch: null, error: result.error };
119
+ const branch = result.body?.default_branch;
120
+ if (!branch) return { ...base, branch: null, error: 'response has no default_branch' };
121
+ return { ...base, branch, error: null };
77
122
  }
78
123
 
79
124
  /** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
@@ -88,6 +133,18 @@ export async function resolveCommit({ source, ref = 'main', token, fetchImpl } =
88
133
  return result.body.sha;
89
134
  }
90
135
 
136
+ const TREE_SHA_RE = /^[0-9a-f]{40}$/;
137
+
138
+ /**
139
+ * lock 的 `skillFolderHash` 形态。CLI 取不到树时(限流)会把 64 位内容哈希写进同一个字段
140
+ * (`add.ts:2254`),所以形态取决于**安装那一刻 API 通不通**,不是取决于来源类型。
141
+ * `tree` = 40 位 git tree SHA;`content` = 64 位内容哈希;`missing` = 没有这个字段。
142
+ */
143
+ export function hashShape(value) {
144
+ if (!value || typeof value !== 'string') return 'missing';
145
+ return TREE_SHA_RE.test(value) ? 'tree' : 'content';
146
+ }
147
+
91
148
  /** `KEY` + `/dir/SKILL.md` → `dir`(F18:`skillPath` 是文件路径,目录运算先 dirname)。 */
92
149
  export function skillDir(skillPath) {
93
150
  const parts = String(skillPath).split('/');
@@ -126,22 +183,41 @@ export function listSkillsFromTree({ tree, rootSha, subpath, repoName }) {
126
183
  }
127
184
 
128
185
  /**
129
- * 单技能判定(L2/L3)。哈希形态必须配对(F19):
130
- * `sourceType=github` 时 lock 里应是 40 位 tree SHA;64 位内容哈希说明来源形态不对,直接给 `unknown`。
186
+ * 单技能判定(L2/L3)。
187
+ *
188
+ * 基线取「第一个 40 位 tree SHA」:优先 lock 的 `skillFolderHash`,其次安装时记下的
189
+ * `state.installs[].treeSha`。CLI 在限流时会退化成 64 位内容哈希(`add.ts:2236`/`2254`),
190
+ * 那玩意儿与上游 tree SHA 不同源,直接比必然不等 —— 既不能判 `current`,也不该判
191
+ * `update-available`,此时改用手边记下的基线;两样都没有才给 `unknown`(老状态,行为与以前一致)。
131
192
  */
132
- export function checkSkill({ lockHash, snapshotEntry, tree, rootSha }) {
193
+ export function checkSkill({ lockHash, recordedTreeSha, snapshotEntry, tree, rootSha }) {
133
194
  if (!snapshotEntry) return { status: 'upstream-deleted', detail: '上游整树里已找不到这个 skillPath' };
134
- if (!lockHash) return { status: 'unknown', detail: 'lock 里没有 skillFolderHash' };
135
- if (!/^[0-9a-f]{40}$/.test(lockHash)) {
195
+ const baseline = TREE_SHA_RE.test(String(lockHash || ''))
196
+ ? { sha: lockHash, from: 'lock' }
197
+ : TREE_SHA_RE.test(String(recordedTreeSha || ''))
198
+ ? { sha: recordedTreeSha, from: 'recorded' }
199
+ : null;
200
+ if (!baseline) {
136
201
  return {
137
202
  status: 'unknown',
138
- detail: 'lock 的哈希不是 40 位 tree SHA(形态不配对),无法按 tree 比对',
203
+ detail: 'lock 里没有 40 位 tree SHA,安装时也未记录基线,无法按 tree 比对',
139
204
  };
140
205
  }
141
206
  const target = skillTreeSha({ tree, skillPath: snapshotEntry.skillPath, rootSha });
142
207
  if (!target) return { status: 'unknown', detail: '整树里找不到该目录的 tree 条目' };
143
- if (target === lockHash) return { status: 'current', detail: '与安装时的 tree SHA 一致' };
144
- return { status: 'update-available', detail: 'tree SHA 已变化', targetTreeSha: target };
208
+ if (target === baseline.sha) {
209
+ return {
210
+ status: 'current',
211
+ detail: baseline.from === 'lock' ? '与安装时的 tree SHA 一致' : '与安装时记录的 tree SHA 一致',
212
+ baseline: baseline.from,
213
+ };
214
+ }
215
+ return {
216
+ status: 'update-available',
217
+ detail: `tree SHA 已变化(基线来自 ${baseline.from})`,
218
+ targetTreeSha: target,
219
+ baseline: baseline.from,
220
+ };
145
221
  }
146
222
 
147
223
  /** 计划 → 应用的防换手 token:磁盘或上游在"计划"之后被改动就拒绝(设计 §7.4③)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",
@@ -68,6 +68,9 @@
68
68
  "dsh-plugin",
69
69
  "skills"
70
70
  ],
71
+ "dependencies": {
72
+ "skills": "1.7.4"
73
+ },
71
74
  "devDependencies": {
72
75
  "esbuild": "0.28.2"
73
76
  }