@morehao/dsh-skills-console 0.2.1 → 0.2.3

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.
@@ -2,12 +2,15 @@ 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';
8
9
  import {
9
10
  checkSkill,
11
+ fetchDefaultBranch,
10
12
  fetchTree,
13
+ hashShape,
11
14
  listSkillsFromTree,
12
15
  parseSource,
13
16
  planToken,
@@ -30,6 +33,118 @@ function nowIso() {
30
33
  return new Date().toISOString();
31
34
  }
32
35
 
36
+ /** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
37
+ function formatClock(ms) {
38
+ const at = new Date(ms);
39
+ const pad = (value) => String(value).padStart(2, '0');
40
+ return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
41
+ }
42
+
43
+ /**
44
+ * 上游失败 → 可执行错误(T1)。四类必须互不相同,否则用户只知道「读不到」,
45
+ * 不知道该等、该改来源,还是该查网络。
46
+ */
47
+ function upstreamError(source, failure) {
48
+ const status = failure?.status ?? null;
49
+ const resetAt = failure?.resetAt ?? null;
50
+ const detail = { source, status, resetAt, error: failure?.error ?? null };
51
+ if (failure?.rateLimited) {
52
+ const resume = resetAt ? `约 ${formatClock(resetAt)} 后自动恢复;` : '';
53
+ return new ServiceError(
54
+ 'error.skills.rate-limited',
55
+ `GitHub 接口已限流(匿名 60 次/小时,按出口 IP 共享)。${resume}设置 GITHUB_TOKEN 可提到 5000 次/小时`,
56
+ detail,
57
+ );
58
+ }
59
+ if (status === 404) {
60
+ return new ServiceError('error.skills.repo-not-found', `读不到 ${source}:仓库不存在或为私有`, detail);
61
+ }
62
+ if (status === 401 || status === 403) {
63
+ return new ServiceError('error.skills.repo-forbidden', `读取 ${source} 被拒绝(HTTP ${status})`, detail);
64
+ }
65
+ if (status) {
66
+ return new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:HTTP ${status}`, detail);
67
+ }
68
+ return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
69
+ }
70
+
71
+ /** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
72
+ function installedNamesFrom(stdout) {
73
+ try {
74
+ const parsed = JSON.parse(stdout);
75
+ const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
76
+ const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
77
+ return [...new Set(names)];
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
84
+ function parseSkillFrontmatter(text) {
85
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
86
+ if (!match) return {};
87
+ const lines = match[1].split(/\r?\n/);
88
+ const fields = {};
89
+ for (let index = 0; index < lines.length; index += 1) {
90
+ const line = lines[index];
91
+ if (!line || /^\s/.test(line)) continue;
92
+ const colon = line.indexOf(':');
93
+ if (colon <= 0) continue;
94
+ const key = line.slice(0, colon).trim();
95
+ if (key !== 'name' && key !== 'description') continue;
96
+ let value = line.slice(colon + 1).trim();
97
+ const block = /^([|>])[+-]?$/.exec(value);
98
+ if (block) {
99
+ const folded = block[1] === '>';
100
+ const rows = [];
101
+ while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
102
+ rows.push(lines[index + 1].replace(/^\s+/, ''));
103
+ index += 1;
104
+ }
105
+ value = folded ? rows.join(' ') : rows.join('\n');
106
+ } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
107
+ const quote = value[0];
108
+ value = value.slice(1, -1);
109
+ if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
110
+ }
111
+ fields[key] = value.trim();
112
+ }
113
+ return fields;
114
+ }
115
+
116
+ /** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
117
+ async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
118
+ const results = new Array(entries.length);
119
+ let cursor = 0;
120
+ const worker = async () => {
121
+ while (cursor < entries.length) {
122
+ const index = cursor;
123
+ cursor += 1;
124
+ results[index] = await inspectSkillEntry({ source, ref, entry: entries[index], token, fetchImpl });
125
+ }
126
+ };
127
+ await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, worker));
128
+ return results;
129
+ }
130
+
131
+ async function inspectSkillEntry({ source, ref, entry, token, fetchImpl }) {
132
+ const fallback = { name: entry.name, description: '', skillPath: entry.skillPath, valid: true };
133
+ try {
134
+ const url = `https://raw.githubusercontent.com/${source}/${ref}/${entry.skillPath.split('/').map(encodeURIComponent).join('/')}`;
135
+ const response = await fetchImpl(url, {
136
+ headers: { 'user-agent': 'dsh-skills-console', ...(token ? { authorization: `Bearer ${token}` } : {}) },
137
+ });
138
+ if (!response?.ok) return fallback;
139
+ const fields = parseSkillFrontmatter(await response.text());
140
+ // CLI 要求 name + description 都在,缺一个就整个跳过:这里同步标成 invalid,别让用户选了才发现装不上
141
+ if (!fields.name || !fields.description) return { ...fallback, valid: false };
142
+ return { name: fields.name, description: fields.description, skillPath: entry.skillPath, valid: true };
143
+ } catch {
144
+ return fallback;
145
+ }
146
+ }
147
+
33
148
  /** 写操作全局单写者队列:`add` 会同时改 canonical 与 lock,按技能分桶没有意义(设计 §14.3)。 */
34
149
  function createQueue(logger) {
35
150
  let chain = Promise.resolve();
@@ -63,6 +178,11 @@ function createQueue(logger) {
63
178
  export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
64
179
  const enqueue = createQueue(logger);
65
180
  const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
181
+ const git = createGit({ env, logger });
182
+ // 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
183
+ const probeCache = new Map();
184
+ const PROBE_TTL_MS = 60_000;
185
+ const PROBE_MAX = 50;
66
186
 
67
187
  function state() {
68
188
  return store.state;
@@ -121,8 +241,267 @@ export function createService({ paths, store, registry, cli, logger, env = proce
121
241
  return { created, skipped, failed };
122
242
  }
123
243
 
124
- function recordLink(name, target, linkPath, mode, created) {
125
- if (store.readOnly) return;
244
+ /**
245
+ * 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。
246
+ * 返回 `{ branch, failure }`:`failure` 原样透出 `fetchDefaultBranch` 的结构化失败,
247
+ * 由 `readSnapshot` 决定报哪种错误(T1)。
248
+ */
249
+ async function branchOf(subscription, { persist = false } = {}) {
250
+ if (subscription.ref) return { branch: subscription.ref, failure: null };
251
+ let resolved;
252
+ try {
253
+ resolved = await fetchDefaultBranch({
254
+ source: subscription.source,
255
+ token: tokenOf,
256
+ fetchImpl,
257
+ });
258
+ } catch (error) {
259
+ return { branch: null, failure: { status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) } };
260
+ }
261
+ const branch = resolved?.branch || null;
262
+ if (!branch) return { branch: null, failure: resolved || null };
263
+ if (persist && !store.readOnly) {
264
+ store.update((draft) => {
265
+ const row = draft.subscriptions.find((item) => item.id === subscription.id);
266
+ if (row && !row.ref) row.ref = branch;
267
+ });
268
+ }
269
+ return { branch, failure: null };
270
+ }
271
+
272
+ /**
273
+ * 读一次订阅快照:只有网络与纯计算,**不写 store**(`persistRef` 例外,只补订阅的 ref)。
274
+ * 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
275
+ * 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
276
+ *
277
+ * T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
278
+ */
279
+ async function readSnapshot(subscription, { force = false, withCommit = true, persistRef = false } = {}) {
280
+ const source = subscription.source;
281
+ try {
282
+ const { branch, failure } = await branchOf(subscription, { persist: persistRef });
283
+ if (!branch) {
284
+ if (failure?.rateLimited || failure?.status === 404) {
285
+ const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
286
+ if (viaGit) return viaGit;
287
+ }
288
+ throw upstreamError(source, failure);
289
+ }
290
+ const result = await fetchTree({
291
+ source,
292
+ ref: branch,
293
+ etag: force ? null : subscription.snapshot?.etag || null,
294
+ token: tokenOf,
295
+ fetchImpl,
296
+ });
297
+ if (result.error) {
298
+ // 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
299
+ if (result.rateLimited) {
300
+ const viaGit = await gitSnapshot(subscription, { ref: branch });
301
+ if (viaGit) return viaGit;
302
+ throw upstreamError(source, {
303
+ status: result.status ?? null,
304
+ rateLimited: true,
305
+ resetAt: result.resetAt ?? null,
306
+ error: result.error,
307
+ });
308
+ }
309
+ throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
310
+ source,
311
+ ref: branch,
312
+ status: result.status ?? null,
313
+ });
314
+ }
315
+ if (result.notModified) return { notModified: true, branch };
316
+ const entries = listSkillsFromTree({
317
+ tree: result.tree,
318
+ rootSha: result.rootSha,
319
+ subpath: subscription.subpath,
320
+ repoName: source.split('/').pop(),
321
+ });
322
+ const previousRoot = subscription.snapshot?.rootSha || null;
323
+ // 探测只需要文件树,不付 resolveCommit 那一次请求
324
+ const commit = !withCommit
325
+ ? null
326
+ : previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
327
+ ? subscription.snapshot.commit
328
+ : await resolveCommit({ source, ref: branch, token: tokenOf, fetchImpl });
329
+ const changed =
330
+ !subscription.snapshot ||
331
+ previousRoot !== result.rootSha ||
332
+ JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
333
+ return {
334
+ branch,
335
+ changed,
336
+ via: 'rest',
337
+ tree: result.tree,
338
+ rootSha: result.rootSha,
339
+ snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
340
+ };
341
+ } catch (error) {
342
+ if (error instanceof ServiceError) throw error;
343
+ throw new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:${error?.message || error}`, {
344
+ source,
345
+ });
346
+ }
347
+ }
348
+
349
+ /**
350
+ * T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
351
+ * 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
352
+ */
353
+ async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
354
+ const source = subscription.source;
355
+ const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
356
+ if (!git.enabled()) return null;
357
+ const startedAt = Date.now();
358
+ let url;
359
+ try {
360
+ url = git.url(source);
361
+ } catch (error) {
362
+ logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
363
+ return null;
364
+ }
365
+ if (!(await git.isAvailable())) {
366
+ logger('git.fallback', { source, kind: 'git-missing', purpose });
367
+ return null;
368
+ }
369
+ try {
370
+ let branch = ref || null;
371
+ if (!branch) {
372
+ const head = await git.lsRemote({ url });
373
+ branch = head.branch || 'main';
374
+ }
375
+ const tree = await git.tree({ url, ref: branch });
376
+ const entries = listSkillsFromTree({
377
+ tree: tree.entries,
378
+ rootSha: tree.rootSha,
379
+ subpath: subscription.subpath,
380
+ repoName: source.split('/').pop(),
381
+ });
382
+ const changed =
383
+ !subscription.snapshot ||
384
+ subscription.snapshot.rootSha !== tree.rootSha ||
385
+ JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
386
+ logger(event, {
387
+ source,
388
+ ref: branch,
389
+ commit: tree.commit,
390
+ entries: entries.length,
391
+ changed,
392
+ ms: Date.now() - startedAt,
393
+ });
394
+ return {
395
+ branch,
396
+ changed,
397
+ via: 'git',
398
+ tree: tree.entries,
399
+ rootSha: tree.rootSha,
400
+ snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
401
+ };
402
+ } catch (error) {
403
+ logger('git.fallback', {
404
+ source,
405
+ kind: error.kind || 'clone-failed',
406
+ message: error.message,
407
+ purpose,
408
+ ms: Date.now() - startedAt,
409
+ });
410
+ return null;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
416
+ * 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
417
+ */
418
+ async function remoteCommitUnchanged(subscription) {
419
+ const commit = subscription.snapshot?.commit;
420
+ if (!commit || !git.enabled()) return false;
421
+ if (!(await git.isAvailable())) return false;
422
+ try {
423
+ const head = await git.lsRemote({ url: git.url(subscription.source) });
424
+ if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
425
+ return head.commit === commit;
426
+ } catch (error) {
427
+ logger('git.fallback', {
428
+ source: subscription.source,
429
+ kind: error.kind || 'clone-failed',
430
+ purpose: 'refresh',
431
+ });
432
+ return false;
433
+ }
434
+ }
435
+
436
+ /**
437
+ * 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
438
+ * 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
439
+ * 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
440
+ */
441
+ async function probeViaGit({ source, ref, subpath }) {
442
+ const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
443
+ if (!result) return null;
444
+ return { branch: result.branch, via: 'git', snapshot: result.snapshot };
445
+ }
446
+
447
+ /**
448
+ * T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
449
+ * 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
450
+ * `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
451
+ *
452
+ * best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
453
+ */
454
+ async function backfillBaselines({ names, source, lock, ref }) {
455
+ if (!git.enabled() || names.length === 0) return [];
456
+ const targets = [];
457
+ for (const name of names) {
458
+ const lockEntry = lock.skills?.[name];
459
+ if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
460
+ const install = store.findInstall(name);
461
+ if (!install || install.treeSha) continue;
462
+ targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
463
+ }
464
+ if (targets.length === 0) return [];
465
+ if (!(await git.isAvailable())) {
466
+ logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
467
+ return [];
468
+ }
469
+ const startedAt = Date.now();
470
+ try {
471
+ const url = git.url(source);
472
+ let useRef = ref || null;
473
+ if (!useRef) useRef = (await git.lsRemote({ url })).commit;
474
+ // 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
475
+ const tree = await git.tree({ url, ref: useRef });
476
+ const updated = [];
477
+ for (const target of targets) {
478
+ const treeSha = target.skillPath
479
+ ? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
480
+ : null;
481
+ if (!treeSha) {
482
+ logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
483
+ continue;
484
+ }
485
+ store.update((draft) => {
486
+ const row = draft.installs.find((item) => item.name === target.name);
487
+ if (row && !row.treeSha) row.treeSha = treeSha;
488
+ });
489
+ updated.push({ name: target.name, treeSha });
490
+ }
491
+ if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
492
+ return updated;
493
+ } catch (error) {
494
+ logger('git.error', {
495
+ source,
496
+ kind: error.kind || 'clone-failed',
497
+ message: error.message,
498
+ purpose: 'baseline',
499
+ });
500
+ return [];
501
+ }
502
+ }
503
+
504
+ function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
126
505
  const existing = state().links.find((item) => item.skill === name && item.target === target.target);
127
506
  if (existing) return;
128
507
  if (!created) return;
@@ -203,6 +582,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
203
582
  skillPath: record.skillPath,
204
583
  ref: record.ref,
205
584
  commit: null,
585
+ treeSha: null,
206
586
  installedHash: record.installedHash,
207
587
  installedAt: nowIso(),
208
588
  updatedAt: nowIso(),
@@ -455,8 +835,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
455
835
  if (!capabilities.usable) {
456
836
  throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
457
837
  }
458
- const names = (skills || []).filter(Boolean);
459
- if (names.length === 0) throw new ServiceError('error.skills.bad-request', 'skills 不能为空');
838
+ // 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
839
+ const requested = (skills || []).filter(Boolean);
460
840
 
461
841
  return enqueue('install', async () => {
462
842
  const subscription = subscriptionId
@@ -467,10 +847,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
467
847
  if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
468
848
 
469
849
  const snapshot = subscription?.snapshot || null;
850
+ const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
470
851
  const pin = commit || snapshot?.commit || subscription?.ref || null;
471
852
  const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
472
853
 
473
- const result = await cli.add({ source: sourceArg, skills: names });
854
+ const result = await cli.add({ source: sourceArg, skills: planned });
474
855
  if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
475
856
  if (result.code !== 0) {
476
857
  throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
@@ -478,6 +859,13 @@ export function createService({ paths, store, registry, cli, logger, env = proce
478
859
  stderr: result.stderr.slice(-2000),
479
860
  });
480
861
  }
862
+ // 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
863
+ const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
864
+ if (names.length === 0) {
865
+ throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
866
+ stdout: result.stdout.slice(-2000),
867
+ });
868
+ }
481
869
 
482
870
  const lock = readLock(paths);
483
871
  const installed = [];
@@ -493,12 +881,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
493
881
  warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
494
882
  }
495
883
  const expected = snapshot?.entries?.find((entry) => entry.name === name);
496
- if (expected?.treeSha && lockEntry?.skillFolderHash && lockEntry.skillFolderHash !== expected.treeSha) {
884
+ const lockHash = lockEntry?.skillFolderHash || null;
885
+ const shape = hashShape(lockHash);
886
+ // 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
887
+ // (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
888
+ if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
497
889
  warnings.push({
498
890
  name,
499
891
  warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
500
892
  expected: expected.treeSha,
501
- actual: lockEntry.skillFolderHash,
893
+ actual: lockHash,
502
894
  });
503
895
  }
504
896
  const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
@@ -508,6 +900,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
508
900
  skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
509
901
  ref: pin,
510
902
  commit: snapshot?.commit || null,
903
+ // 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
904
+ treeSha: expected?.treeSha || null,
511
905
  installedHash,
512
906
  installedAt: nowIso(),
513
907
  updatedAt: nowIso(),
@@ -520,7 +914,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
520
914
  if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
521
915
  else draft.installs.push(record);
522
916
  });
523
- installed.push({ name, lockHash: lockEntry?.skillFolderHash || null, installedHash });
917
+ installed.push({ name, lockHash, lockHashShape: shape, installedHash });
524
918
  }
525
919
 
526
920
  const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
@@ -532,6 +926,14 @@ export function createService({ paths, store, registry, cli, logger, env = proce
532
926
  linkResult.failed.push(...one.failed);
533
927
  }
534
928
 
929
+ // T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
930
+ // 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
931
+ const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
932
+ for (const item of installed) {
933
+ const filled = baselines.find((row) => row.name === item.name);
934
+ if (filled) item.treeSha = filled.treeSha;
935
+ }
936
+
535
937
  logger('install', { source: sourceArg, skills: names, installed: installed.length });
536
938
  return {
537
939
  ok: true,
@@ -605,26 +1007,148 @@ export function createService({ paths, store, registry, cli, logger, env = proce
605
1007
  });
606
1008
  },
607
1009
 
1010
+ /** 只读探测来源里的 SKILL.md,供「添加技能」多选;不写 state、不建订阅。 */
1011
+ async probeSource({ source, ref, subpath }) {
1012
+ const parsed = parseSource(source);
1013
+ if (!parsed) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
1014
+ const canonicalSource = `${parsed.owner}/${parsed.repo}`;
1015
+ const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
1016
+ const requestedSubpath = typeof subpath === 'string' && subpath.trim()
1017
+ ? subpath.trim().replace(/^\/+|\/+$/g, '') || null
1018
+ : null;
1019
+ const key = `${canonicalSource}@${requestedRef || ''}#${requestedSubpath || ''}`;
1020
+ const cached = probeCache.get(key);
1021
+ if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
1022
+ return cached.pending || cached.value;
1023
+ }
1024
+ const pending = (async () => {
1025
+ // 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
1026
+ const viaGit = await probeViaGit({
1027
+ source: canonicalSource,
1028
+ ref: requestedRef,
1029
+ subpath: requestedSubpath,
1030
+ });
1031
+ const probe =
1032
+ viaGit ||
1033
+ (await readSnapshot(
1034
+ {
1035
+ id: null,
1036
+ source: canonicalSource,
1037
+ ref: requestedRef,
1038
+ subpath: requestedSubpath,
1039
+ snapshot: null,
1040
+ },
1041
+ { force: true, withCommit: false },
1042
+ ));
1043
+ // 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
1044
+ const enrichRef = probe.snapshot?.commit || probe.branch;
1045
+ const enriched = await enrichSkillEntries({
1046
+ source: canonicalSource,
1047
+ ref: enrichRef,
1048
+ entries: probe.snapshot.entries,
1049
+ token: tokenOf,
1050
+ fetchImpl,
1051
+ });
1052
+ const skills = enriched
1053
+ .filter((skill) => skill.valid)
1054
+ .map(({ name, description, skillPath }) => ({ name, description, skillPath }));
1055
+ return {
1056
+ ok: true,
1057
+ source: canonicalSource,
1058
+ ref: probe.branch,
1059
+ via: probe.via || 'rest',
1060
+ skills,
1061
+ skipped: enriched.length - skills.length,
1062
+ };
1063
+ })();
1064
+ probeCache.set(key, { pending, at: Date.now() });
1065
+ try {
1066
+ const value = await pending;
1067
+ probeCache.set(key, { value, at: Date.now() });
1068
+ for (const oldest of probeCache.keys()) {
1069
+ if (probeCache.size <= PROBE_MAX) break;
1070
+ probeCache.delete(oldest);
1071
+ }
1072
+ return value;
1073
+ } catch (error) {
1074
+ // 失败不留缓存,用户改一下来源或重试就能重新探测
1075
+ probeCache.delete(key);
1076
+ throw error;
1077
+ }
1078
+ },
1079
+
608
1080
  async addSubscription({ source, ref, subpath, skills = '*' }) {
609
1081
  assertWritable();
610
1082
  const parsed = parseSource(source);
611
1083
  if (!parsed) throw new ServiceError('error.skills.bad-source', '订阅只接受 owner/repo 或 GitHub URL');
612
- const id = snapshotId({ source, ref, subpath });
613
- const existing = state().subscriptions.find((item) => item.id === id);
614
- if (existing) return { ok: true, subscription: existing, existing: true };
615
- const record = {
616
- id,
617
- source: `${parsed.owner}/${parsed.repo}`,
618
- ref: ref || null,
619
- subpath: subpath || null,
1084
+ const canonicalSource = `${parsed.owner}/${parsed.repo}`;
1085
+ const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
1086
+ const requestedSubpath = typeof subpath === 'string' && subpath.trim()
1087
+ ? subpath.trim().replace(/^\/+|\/+$/g, '') || null
1088
+ : null;
1089
+ const id = snapshotId({ source: canonicalSource, ref: requestedRef, subpath: requestedSubpath });
1090
+ // 老记录可能用 URL / 原始字符串算过 id:按来源+ref+子目录再找一遍,避免同一仓库出现两条订阅
1091
+ const existing =
1092
+ state().subscriptions.find((item) => item.id === id) ||
1093
+ state().subscriptions.find(
1094
+ (item) =>
1095
+ item.source === canonicalSource &&
1096
+ (item.ref || null) === requestedRef &&
1097
+ (item.subpath || null) === requestedSubpath,
1098
+ );
1099
+ // 已有快照的重复添加直接返回;空壳(旧版本失败留下的)要走一次真实拉取,成功后再补全
1100
+ if (existing?.snapshot) return { ok: true, subscription: existing, existing: true };
1101
+
1102
+ const candidate = {
1103
+ id: existing?.id || id,
1104
+ source: canonicalSource,
1105
+ ref: requestedRef || existing?.ref || null,
1106
+ subpath: requestedSubpath,
620
1107
  skills,
621
1108
  lastCheck: null,
622
1109
  snapshot: null,
623
1110
  };
1111
+
1112
+ // 先读快照再落盘:上游失败时 state 里不能留下订阅,接口也必须如实报错
1113
+ const probe = await readSnapshot(candidate, { force: true });
1114
+ // 一条 skill 都没有 = 订阅了也没东西可装(多半是 subpath/ref 写错),同样不能算添加成功
1115
+ if (probe.snapshot.entries.length === 0) {
1116
+ throw new ServiceError(
1117
+ 'error.skills.no-skills',
1118
+ `${canonicalSource}@${probe.branch}${requestedSubpath ? `/${requestedSubpath}` : ''} 里没有找到 SKILL.md`,
1119
+ { source: canonicalSource, ref: probe.branch, subpath: requestedSubpath },
1120
+ );
1121
+ }
1122
+
1123
+ const record = {
1124
+ ...candidate,
1125
+ ...(existing?.adopted ? { adopted: true } : {}),
1126
+ ref: probe.branch,
1127
+ snapshot: probe.snapshot,
1128
+ lastCheck: {
1129
+ at: nowIso(),
1130
+ commit: probe.snapshot.commit,
1131
+ treeEtag: probe.snapshot.etag,
1132
+ status: 'changed',
1133
+ },
1134
+ };
624
1135
  store.update((draft) => {
625
- draft.subscriptions.push(record);
1136
+ const index = draft.subscriptions.findIndex((item) => item.id === record.id);
1137
+ if (index >= 0) draft.subscriptions[index] = { ...draft.subscriptions[index], ...record };
1138
+ else draft.subscriptions.push(record);
1139
+ });
1140
+ logger('subscription.add', {
1141
+ id: record.id,
1142
+ source: record.source,
1143
+ ref: record.ref,
1144
+ entries: record.snapshot.entries.length,
626
1145
  });
627
- return { ok: true, subscription: record, ...(await this.refreshSubscription({ id, force: true })) };
1146
+ return {
1147
+ ok: true,
1148
+ changed: true,
1149
+ entries: record.snapshot.entries.length,
1150
+ subscription: state().subscriptions.find((item) => item.id === record.id),
1151
+ };
628
1152
  },
629
1153
 
630
1154
  async removeSubscription({ id, keepInstalled = true }) {
@@ -646,55 +1170,56 @@ export function createService({ paths, store, registry, cli, logger, env = proce
646
1170
  assertWritable();
647
1171
  const subscription = state().subscriptions.find((item) => item.id === id);
648
1172
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
649
- const ref = subscription.ref || 'main';
650
- const previous = subscription.snapshot?.etag || null;
651
- const result = await fetchTree({
652
- source: subscription.source,
653
- ref,
654
- etag: force ? null : previous,
655
- token: tokenOf,
656
- fetchImpl,
657
- });
658
- if (result.error) {
1173
+ // T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
1174
+ if (await remoteCommitUnchanged(subscription)) {
1175
+ const at = nowIso();
659
1176
  store.update((draft) => {
660
1177
  const row = draft.subscriptions.find((item) => item.id === id);
661
- if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: result.error };
1178
+ if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
662
1179
  });
663
- return { ok: false, code: 'error.skills.upstream-failed', message: result.error };
1180
+ logger('subscription.refresh', { id, notModified: true, via: 'git' });
1181
+ return {
1182
+ ok: true,
1183
+ changed: false,
1184
+ notModified: true,
1185
+ via: 'git',
1186
+ subscription: state().subscriptions.find((item) => item.id === id),
1187
+ };
664
1188
  }
665
- if (result.notModified) {
666
- store.update((draft) => {
667
- const row = draft.subscriptions.find((item) => item.id === id);
668
- // 304 只刷新时间,绝不把 entries 置空
669
- if (row) row.lastCheck = { ...(row.lastCheck || {}), at: nowIso(), status: 'unchanged' };
670
- });
671
- return { ok: true, notModified: true, subscription: state().subscriptions.find((item) => item.id === id) };
1189
+ let probe;
1190
+ try {
1191
+ probe = await readSnapshot(subscription, { force });
1192
+ } catch (error) {
1193
+ // 失败留痕(清单里能看到 lastCheck),但接口必须报错,不能把失败伪装成成功
1194
+ if (!store.readOnly) {
1195
+ store.update((draft) => {
1196
+ const row = draft.subscriptions.find((item) => item.id === id);
1197
+ if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: error.message };
1198
+ });
1199
+ }
1200
+ throw error;
672
1201
  }
673
-
674
- const entries = listSkillsFromTree({
675
- tree: result.tree,
676
- rootSha: result.rootSha,
677
- subpath: subscription.subpath,
678
- repoName: subscription.source.split('/').pop(),
679
- });
680
- const previousRoot = subscription.snapshot?.rootSha || null;
681
- const commit =
682
- previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
683
- ? subscription.snapshot.commit
684
- : await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
685
-
686
- const changed =
687
- !subscription.snapshot ||
688
- previousRoot !== result.rootSha ||
689
- JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
1202
+ const at = nowIso();
690
1203
  store.update((draft) => {
691
1204
  const row = draft.subscriptions.find((item) => item.id === id);
692
1205
  if (!row) return;
693
- row.snapshot = { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag };
694
- row.lastCheck = { at: nowIso(), commit, treeEtag: result.etag, status: changed ? 'changed' : 'unchanged' };
1206
+ if (!row.ref && probe.branch) row.ref = probe.branch;
1207
+ if (probe.notModified) {
1208
+ // 304 只刷新时间,绝不把 entries 置空
1209
+ row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
1210
+ return;
1211
+ }
1212
+ row.snapshot = probe.snapshot;
1213
+ row.lastCheck = { at, commit: probe.snapshot.commit, treeEtag: probe.snapshot.etag, status: probe.changed ? 'changed' : 'unchanged' };
695
1214
  });
696
- logger('subscription.refresh', { id, entries: entries.length, changed });
697
- return { ok: true, changed, subscription: state().subscriptions.find((item) => item.id === id) };
1215
+ if (!probe.notModified) logger('subscription.refresh', { id, entries: probe.snapshot.entries.length, changed: probe.changed });
1216
+ return {
1217
+ ok: true,
1218
+ changed: !probe.notModified && probe.changed,
1219
+ ...(probe.notModified ? { notModified: true } : {}),
1220
+ via: probe.via || 'rest',
1221
+ subscription: state().subscriptions.find((item) => item.id === id),
1222
+ };
698
1223
  },
699
1224
 
700
1225
  async checkUpdates({ names, subscriptionId } = {}) {
@@ -708,27 +1233,31 @@ export function createService({ paths, store, registry, cli, logger, env = proce
708
1233
  for (const install of installs) {
709
1234
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
710
1235
  if (!subscription) continue;
711
- const key = `${subscription.source}#${subscription.ref || 'main'}`;
712
- if (!groups.has(key)) groups.set(key, { subscription, installs: [] });
713
- groups.get(key).installs.push(install);
1236
+ // 按订阅(id 已含 source/ref/subpath)分组:不能用 `ref || 'main'` 当键,否则 null-ref 与 main 会撞车
1237
+ if (!groups.has(subscription.id)) groups.set(subscription.id, { subscription, installs: [] });
1238
+ groups.get(subscription.id).installs.push(install);
714
1239
  }
715
1240
 
716
1241
  const results = [];
717
1242
  for (const { subscription, installs: groupInstalls } of groups.values()) {
718
- const tree = await fetchTree({
719
- source: subscription.source,
720
- ref: subscription.ref || 'main',
721
- etag: subscription.snapshot?.etag || null,
722
- token: tokenOf,
723
- fetchImpl,
724
- });
725
- if (tree.error) {
1243
+ // T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
1244
+ // withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
1245
+ let probe;
1246
+ try {
1247
+ probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
1248
+ } catch (error) {
726
1249
  for (const install of groupInstalls) {
727
- results.push({ name: install.name, status: 'unknown', detail: tree.error, checkedAt: nowIso() });
1250
+ results.push({
1251
+ name: install.name,
1252
+ status: 'unknown',
1253
+ detail: error.message,
1254
+ code: error.code || null,
1255
+ checkedAt: nowIso(),
1256
+ });
728
1257
  }
729
1258
  continue;
730
1259
  }
731
- if (tree.notModified) {
1260
+ if (probe.notModified) {
732
1261
  for (const install of groupInstalls) {
733
1262
  const previousCheck = state().checks?.[install.name];
734
1263
  results.push({
@@ -742,16 +1271,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
742
1271
  }
743
1272
  for (const install of groupInstalls) {
744
1273
  const skillPath = lock.skills?.[install.name]?.skillPath || install.skillPath;
745
- const entry = tree.tree.some((item) => item.type === 'blob' && item.path === skillPath)
1274
+ const entry = probe.tree.some((item) => item.type === 'blob' && item.path === skillPath)
746
1275
  ? { skillPath }
747
1276
  : null;
748
1277
  const verdict = checkSkill({
749
1278
  lockHash: lock.skills?.[install.name]?.skillFolderHash || null,
1279
+ recordedTreeSha: install.treeSha ?? null,
750
1280
  snapshotEntry: entry,
751
- tree: tree.tree,
752
- rootSha: tree.rootSha,
1281
+ tree: probe.tree,
1282
+ rootSha: probe.rootSha,
753
1283
  });
754
- results.push({ name: install.name, ...verdict, checkedAt: nowIso() });
1284
+ results.push({ name: install.name, ...verdict, via: probe.via, checkedAt: nowIso() });
755
1285
  }
756
1286
  }
757
1287
 
@@ -779,19 +1309,15 @@ export function createService({ paths, store, registry, cli, logger, env = proce
779
1309
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
780
1310
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
781
1311
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
782
- const tree = await fetchTree({
783
- source: subscription.source,
784
- ref: subscription.ref || 'main',
785
- token: tokenOf,
786
- fetchImpl,
787
- });
788
- if (tree.error) throw new ServiceError('error.skills.upstream-failed', tree.error);
1312
+ // 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
1313
+ const probe = await readSnapshot(subscription, { force: true });
1314
+ const ref = probe.branch;
789
1315
  const lock = readLock(paths);
790
1316
  const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
791
- const entry = tree.tree.find((item) => item.type === 'blob' && item.path === skillPath);
1317
+ const entry = probe.tree.find((item) => item.type === 'blob' && item.path === skillPath);
792
1318
  if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
793
- const targetTreeSha = skillTreeSha({ tree: tree.tree, skillPath, rootSha: tree.rootSha });
794
- const targetCommit = await resolveCommit({ source: subscription.source, ref: subscription.ref || 'main', token: tokenOf, fetchImpl });
1319
+ const targetTreeSha = skillTreeSha({ tree: probe.tree, skillPath, rootSha: probe.rootSha });
1320
+ const targetCommit = probe.snapshot?.commit ?? null;
795
1321
  const token = planToken({
796
1322
  subscriptionId: subscription.id,
797
1323
  from: install.commit || null,
@@ -806,6 +1332,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
806
1332
  token,
807
1333
  fromCommit: install.commit || null,
808
1334
  toCommit: targetCommit,
1335
+ ref,
809
1336
  targetTreeSha,
810
1337
  skillPath,
811
1338
  localModified: Boolean(install.installedHash) && contentHash(path.join(paths.canonical, name)) !== install.installedHash,
@@ -833,7 +1360,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
833
1360
  ? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
834
1361
  : null;
835
1362
 
836
- const sourceArg = plan.toCommit ? `${subscription.source}#${plan.toCommit}` : `${subscription.source}#${subscription.ref || 'main'}`;
1363
+ const sourceArg = plan.toCommit
1364
+ ? `${subscription.source}#${plan.toCommit}`
1365
+ : `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
837
1366
  const result = await cli.add({ source: sourceArg, skills: [name] });
838
1367
  const lock = readLock(paths);
839
1368
  const lockHash = lock.skills?.[name]?.skillFolderHash || null;
@@ -857,9 +1386,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
857
1386
  const row = draft.installs.find((item) => item.name === name);
858
1387
  if (!row) return;
859
1388
  row.commit = plan.toCommit;
1389
+ // 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
1390
+ row.treeSha = plan.targetTreeSha || row.treeSha || null;
860
1391
  row.installedHash = installedHash;
861
1392
  row.updatedAt = nowIso();
862
- row.backup = backup ? { key: backup.key, hash: install.installedHash || null } : row.backup;
1393
+ row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
863
1394
  draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
864
1395
  });
865
1396
 
@@ -886,6 +1417,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
886
1417
  row.installedHash = installedHash;
887
1418
  row.updatedAt = nowIso();
888
1419
  row.commit = null;
1420
+ // 回滚回去的是备份那一刻的内容,tree 基线也得跟着退回去(不知道就置空,绝不沿用新版)
1421
+ row.treeSha = install.backup.treeSha ?? null;
889
1422
  draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
890
1423
  });
891
1424
  logger('updates.rollback', { name, backup: install.backup.key });