@morehao/dsh-skills-console 0.2.0 → 0.2.2

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/icon.svg CHANGED
@@ -1,4 +1,5 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Skills Manager">
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="技能控制台">
2
+ <title>技能控制台</title>
2
3
  <defs>
3
4
  <linearGradient id="dshsc-icon-bg" x1="6" y1="2" x2="58" y2="62" gradientUnits="userSpaceOnUse">
4
5
  <stop stop-color="#3b82f6"/>
package/lib/core/cli.js CHANGED
@@ -213,10 +213,12 @@ export function createCli({ env = process.env, logger = () => {} } = {}) {
213
213
 
214
214
  /**
215
215
  * 安装:`-a universal` 必带(F17),`--json` 配 `-y`(F11),`--full-depth` 必带(F24)。
216
+ * 技能名留空 = `--skill '*'`(整仓全部);不能留空值的 `--skill`,那会把后面的 `-a` 当成它的参数。
216
217
  * 永不带 `--all` / `-a '*'`。
217
218
  */
218
219
  async add({ source, skills = [], timeoutMs = env.DSH_SKILLS_CONSOLE_TIMEOUT_MS ? Number(env.DSH_SKILLS_CONSOLE_TIMEOUT_MS) : 120000 }) {
219
- const args = ['add', source, '--skill', ...skills, '-a', 'universal', '-g', '-y', '--json', '--full-depth'];
220
+ const names = skills.length > 0 ? skills : ['*'];
221
+ const args = ['add', source, '--skill', ...names, '-a', 'universal', '-g', '-y', '--json', '--full-depth'];
220
222
  const result = await call(args, { timeoutMs });
221
223
  return result;
222
224
  },
package/lib/core/links.js CHANGED
@@ -55,7 +55,7 @@ export function inspectTarget({ linkPath, canonicalPath }) {
55
55
  }
56
56
 
57
57
  /**
58
- * 建链:同层用相对路径(可跨机器迁移),算不出相对就退回绝对路径并标记。
58
+ * 建链:同层用相对路径(可跨机器复用),算不出相对就退回绝对路径并标记。
59
59
  * Windows 用 junction + 绝对路径(对齐 CLI 的 createSymlink,F2)。
60
60
  */
61
61
  export function createLink({ linkPath, targetPath, platform = process.platform, fsImpl = fs }) {
@@ -7,6 +7,7 @@ import { buildLedger, readLock, scanCanonical } from './inventory.js';
7
7
  import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
8
8
  import {
9
9
  checkSkill,
10
+ fetchDefaultBranch,
10
11
  fetchTree,
11
12
  listSkillsFromTree,
12
13
  parseSource,
@@ -30,6 +31,83 @@ function nowIso() {
30
31
  return new Date().toISOString();
31
32
  }
32
33
 
34
+ /** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
35
+ function installedNamesFrom(stdout) {
36
+ try {
37
+ const parsed = JSON.parse(stdout);
38
+ const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
39
+ const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
40
+ return [...new Set(names)];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ /** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
47
+ function parseSkillFrontmatter(text) {
48
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
49
+ if (!match) return {};
50
+ const lines = match[1].split(/\r?\n/);
51
+ const fields = {};
52
+ for (let index = 0; index < lines.length; index += 1) {
53
+ const line = lines[index];
54
+ if (!line || /^\s/.test(line)) continue;
55
+ const colon = line.indexOf(':');
56
+ if (colon <= 0) continue;
57
+ const key = line.slice(0, colon).trim();
58
+ if (key !== 'name' && key !== 'description') continue;
59
+ let value = line.slice(colon + 1).trim();
60
+ const block = /^([|>])[+-]?$/.exec(value);
61
+ if (block) {
62
+ const folded = block[1] === '>';
63
+ const rows = [];
64
+ while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
65
+ rows.push(lines[index + 1].replace(/^\s+/, ''));
66
+ index += 1;
67
+ }
68
+ value = folded ? rows.join(' ') : rows.join('\n');
69
+ } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
70
+ const quote = value[0];
71
+ value = value.slice(1, -1);
72
+ if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
73
+ }
74
+ fields[key] = value.trim();
75
+ }
76
+ return fields;
77
+ }
78
+
79
+ /** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
80
+ async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
81
+ const results = new Array(entries.length);
82
+ let cursor = 0;
83
+ const worker = async () => {
84
+ while (cursor < entries.length) {
85
+ const index = cursor;
86
+ cursor += 1;
87
+ results[index] = await inspectSkillEntry({ source, ref, entry: entries[index], token, fetchImpl });
88
+ }
89
+ };
90
+ await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, worker));
91
+ return results;
92
+ }
93
+
94
+ async function inspectSkillEntry({ source, ref, entry, token, fetchImpl }) {
95
+ const fallback = { name: entry.name, description: '', skillPath: entry.skillPath, valid: true };
96
+ try {
97
+ const url = `https://raw.githubusercontent.com/${source}/${ref}/${entry.skillPath.split('/').map(encodeURIComponent).join('/')}`;
98
+ const response = await fetchImpl(url, {
99
+ headers: { 'user-agent': 'dsh-skills-console', ...(token ? { authorization: `Bearer ${token}` } : {}) },
100
+ });
101
+ if (!response?.ok) return fallback;
102
+ const fields = parseSkillFrontmatter(await response.text());
103
+ // CLI 要求 name + description 都在,缺一个就整个跳过:这里同步标成 invalid,别让用户选了才发现装不上
104
+ if (!fields.name || !fields.description) return { ...fallback, valid: false };
105
+ return { name: fields.name, description: fields.description, skillPath: entry.skillPath, valid: true };
106
+ } catch {
107
+ return fallback;
108
+ }
109
+ }
110
+
33
111
  /** 写操作全局单写者队列:`add` 会同时改 canonical 与 lock,按技能分桶没有意义(设计 §14.3)。 */
34
112
  function createQueue(logger) {
35
113
  let chain = Promise.resolve();
@@ -63,6 +141,10 @@ function createQueue(logger) {
63
141
  export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
64
142
  const enqueue = createQueue(logger);
65
143
  const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
144
+ // 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
145
+ const probeCache = new Map();
146
+ const PROBE_TTL_MS = 60_000;
147
+ const PROBE_MAX = 50;
66
148
 
67
149
  function state() {
68
150
  return store.state;
@@ -78,7 +160,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
78
160
 
79
161
  function defaultTargets(current) {
80
162
  const targets = ['dsh-global'];
81
- // null = 老语义「所有探测到的 Agent」;数组 = 用户在「自动链接」里勾选的那些
163
+ // null = 老语义「所有探测到的 Agent」;数组 = 用户在「自动接入」里勾选的那些
82
164
  const selected = current.settings.autoLinkTargets ?? null;
83
165
  for (const target of detectedTargets()) {
84
166
  if (target.kind !== 'agent') continue;
@@ -121,6 +203,90 @@ export function createService({ paths, store, registry, cli, logger, env = proce
121
203
  return { created, skipped, failed };
122
204
  }
123
205
 
206
+ /** 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。 */
207
+ async function branchOf(subscription, { persist = false } = {}) {
208
+ if (subscription.ref) return subscription.ref;
209
+ let branch = null;
210
+ try {
211
+ branch = await fetchDefaultBranch({
212
+ source: subscription.source,
213
+ token: tokenOf,
214
+ fetchImpl,
215
+ });
216
+ } catch {
217
+ branch = null;
218
+ }
219
+ if (!branch) return null;
220
+ if (persist && !store.readOnly) {
221
+ store.update((draft) => {
222
+ const row = draft.subscriptions.find((item) => item.id === subscription.id);
223
+ if (row && !row.ref) row.ref = branch;
224
+ });
225
+ }
226
+ return branch;
227
+ }
228
+
229
+ /**
230
+ * 读一次订阅快照:只有网络与纯计算,**不写 store**。
231
+ * 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
232
+ * 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
233
+ */
234
+ async function readSnapshot(subscription, { force = false, withCommit = true } = {}) {
235
+ const source = subscription.source;
236
+ try {
237
+ const branch = await branchOf(subscription);
238
+ if (!branch) {
239
+ throw new ServiceError(
240
+ 'error.skills.upstream-failed',
241
+ `读不到 ${source} 的默认分支(仓库不存在、私有,或 GitHub 接口不可用/已限流)`,
242
+ { source },
243
+ );
244
+ }
245
+ const result = await fetchTree({
246
+ source,
247
+ ref: branch,
248
+ etag: force ? null : subscription.snapshot?.etag || null,
249
+ token: tokenOf,
250
+ fetchImpl,
251
+ });
252
+ if (result.error) {
253
+ throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
254
+ source,
255
+ ref: branch,
256
+ status: result.status ?? null,
257
+ });
258
+ }
259
+ if (result.notModified) return { notModified: true, branch };
260
+ const entries = listSkillsFromTree({
261
+ tree: result.tree,
262
+ rootSha: result.rootSha,
263
+ subpath: subscription.subpath,
264
+ repoName: source.split('/').pop(),
265
+ });
266
+ const previousRoot = subscription.snapshot?.rootSha || null;
267
+ // 探测只需要文件树,不付 resolveCommit 那一次请求
268
+ const commit = !withCommit
269
+ ? null
270
+ : previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
271
+ ? subscription.snapshot.commit
272
+ : await resolveCommit({ source, ref: branch, token: tokenOf, fetchImpl });
273
+ const changed =
274
+ !subscription.snapshot ||
275
+ previousRoot !== result.rootSha ||
276
+ JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
277
+ return {
278
+ branch,
279
+ changed,
280
+ snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
281
+ };
282
+ } catch (error) {
283
+ if (error instanceof ServiceError) throw error;
284
+ throw new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:${error?.message || error}`, {
285
+ source,
286
+ });
287
+ }
288
+ }
289
+
124
290
  function recordLink(name, target, linkPath, mode, created) {
125
291
  if (store.readOnly) return;
126
292
  const existing = state().links.find((item) => item.skill === name && item.target === target.target);
@@ -455,8 +621,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
455
621
  if (!capabilities.usable) {
456
622
  throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
457
623
  }
458
- const names = (skills || []).filter(Boolean);
459
- if (names.length === 0) throw new ServiceError('error.skills.bad-request', 'skills 不能为空');
624
+ // 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
625
+ const requested = (skills || []).filter(Boolean);
460
626
 
461
627
  return enqueue('install', async () => {
462
628
  const subscription = subscriptionId
@@ -467,10 +633,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
467
633
  if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
468
634
 
469
635
  const snapshot = subscription?.snapshot || null;
636
+ const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
470
637
  const pin = commit || snapshot?.commit || subscription?.ref || null;
471
638
  const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
472
639
 
473
- const result = await cli.add({ source: sourceArg, skills: names });
640
+ const result = await cli.add({ source: sourceArg, skills: planned });
474
641
  if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
475
642
  if (result.code !== 0) {
476
643
  throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
@@ -478,6 +645,13 @@ export function createService({ paths, store, registry, cli, logger, env = proce
478
645
  stderr: result.stderr.slice(-2000),
479
646
  });
480
647
  }
648
+ // 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
649
+ const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
650
+ if (names.length === 0) {
651
+ throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
652
+ stdout: result.stdout.slice(-2000),
653
+ });
654
+ }
481
655
 
482
656
  const lock = readLock(paths);
483
657
  const installed = [];
@@ -596,7 +770,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
596
770
  return {
597
771
  ok: false,
598
772
  code: 'error.skills.cli-failed',
599
- message: 'skills remove 失败;已断开本插件建的链接,真身可能仍在 canonical',
773
+ message: 'skills remove 失败;已断开本插件建立的软链,真身可能仍在 canonical',
600
774
  detail: { code: result.code, stderr: result.stderr.slice(-2000) },
601
775
  trash: trash.key,
602
776
  };
@@ -605,26 +779,136 @@ export function createService({ paths, store, registry, cli, logger, env = proce
605
779
  });
606
780
  },
607
781
 
782
+ /** 只读探测来源里的 SKILL.md,供「添加技能」多选;不写 state、不建订阅。 */
783
+ async probeSource({ source, ref, subpath }) {
784
+ const parsed = parseSource(source);
785
+ if (!parsed) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
786
+ const canonicalSource = `${parsed.owner}/${parsed.repo}`;
787
+ const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
788
+ const requestedSubpath = typeof subpath === 'string' && subpath.trim()
789
+ ? subpath.trim().replace(/^\/+|\/+$/g, '') || null
790
+ : null;
791
+ const key = `${canonicalSource}@${requestedRef || ''}#${requestedSubpath || ''}`;
792
+ const cached = probeCache.get(key);
793
+ if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
794
+ return cached.pending || cached.value;
795
+ }
796
+ const pending = readSnapshot(
797
+ {
798
+ id: null,
799
+ source: canonicalSource,
800
+ ref: requestedRef,
801
+ subpath: requestedSubpath,
802
+ snapshot: null,
803
+ },
804
+ { force: true, withCommit: false },
805
+ ).then(async (probe) => {
806
+ const enriched = await enrichSkillEntries({
807
+ source: canonicalSource,
808
+ ref: probe.branch,
809
+ entries: probe.snapshot.entries,
810
+ token: tokenOf,
811
+ fetchImpl,
812
+ });
813
+ const skills = enriched
814
+ .filter((skill) => skill.valid)
815
+ .map(({ name, description, skillPath }) => ({ name, description, skillPath }));
816
+ return {
817
+ ok: true,
818
+ source: canonicalSource,
819
+ ref: probe.branch,
820
+ skills,
821
+ skipped: enriched.length - skills.length,
822
+ };
823
+ });
824
+ probeCache.set(key, { pending, at: Date.now() });
825
+ try {
826
+ const value = await pending;
827
+ probeCache.set(key, { value, at: Date.now() });
828
+ for (const oldest of probeCache.keys()) {
829
+ if (probeCache.size <= PROBE_MAX) break;
830
+ probeCache.delete(oldest);
831
+ }
832
+ return value;
833
+ } catch (error) {
834
+ // 失败不留缓存,用户改一下来源或重试就能重新探测
835
+ probeCache.delete(key);
836
+ throw error;
837
+ }
838
+ },
839
+
608
840
  async addSubscription({ source, ref, subpath, skills = '*' }) {
609
841
  assertWritable();
610
842
  const parsed = parseSource(source);
611
843
  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,
844
+ const canonicalSource = `${parsed.owner}/${parsed.repo}`;
845
+ const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
846
+ const requestedSubpath = typeof subpath === 'string' && subpath.trim()
847
+ ? subpath.trim().replace(/^\/+|\/+$/g, '') || null
848
+ : null;
849
+ const id = snapshotId({ source: canonicalSource, ref: requestedRef, subpath: requestedSubpath });
850
+ // 老记录可能用 URL / 原始字符串算过 id:按来源+ref+子目录再找一遍,避免同一仓库出现两条订阅
851
+ const existing =
852
+ state().subscriptions.find((item) => item.id === id) ||
853
+ state().subscriptions.find(
854
+ (item) =>
855
+ item.source === canonicalSource &&
856
+ (item.ref || null) === requestedRef &&
857
+ (item.subpath || null) === requestedSubpath,
858
+ );
859
+ // 已有快照的重复添加直接返回;空壳(旧版本失败留下的)要走一次真实拉取,成功后再补全
860
+ if (existing?.snapshot) return { ok: true, subscription: existing, existing: true };
861
+
862
+ const candidate = {
863
+ id: existing?.id || id,
864
+ source: canonicalSource,
865
+ ref: requestedRef || existing?.ref || null,
866
+ subpath: requestedSubpath,
620
867
  skills,
621
868
  lastCheck: null,
622
869
  snapshot: null,
623
870
  };
871
+
872
+ // 先读快照再落盘:上游失败时 state 里不能留下订阅,接口也必须如实报错
873
+ const probe = await readSnapshot(candidate, { force: true });
874
+ // 一条 skill 都没有 = 订阅了也没东西可装(多半是 subpath/ref 写错),同样不能算添加成功
875
+ if (probe.snapshot.entries.length === 0) {
876
+ throw new ServiceError(
877
+ 'error.skills.no-skills',
878
+ `${canonicalSource}@${probe.branch}${requestedSubpath ? `/${requestedSubpath}` : ''} 里没有找到 SKILL.md`,
879
+ { source: canonicalSource, ref: probe.branch, subpath: requestedSubpath },
880
+ );
881
+ }
882
+
883
+ const record = {
884
+ ...candidate,
885
+ ...(existing?.adopted ? { adopted: true } : {}),
886
+ ref: probe.branch,
887
+ snapshot: probe.snapshot,
888
+ lastCheck: {
889
+ at: nowIso(),
890
+ commit: probe.snapshot.commit,
891
+ treeEtag: probe.snapshot.etag,
892
+ status: 'changed',
893
+ },
894
+ };
624
895
  store.update((draft) => {
625
- draft.subscriptions.push(record);
896
+ const index = draft.subscriptions.findIndex((item) => item.id === record.id);
897
+ if (index >= 0) draft.subscriptions[index] = { ...draft.subscriptions[index], ...record };
898
+ else draft.subscriptions.push(record);
626
899
  });
627
- return { ok: true, subscription: record, ...(await this.refreshSubscription({ id, force: true })) };
900
+ logger('subscription.add', {
901
+ id: record.id,
902
+ source: record.source,
903
+ ref: record.ref,
904
+ entries: record.snapshot.entries.length,
905
+ });
906
+ return {
907
+ ok: true,
908
+ changed: true,
909
+ entries: record.snapshot.entries.length,
910
+ subscription: state().subscriptions.find((item) => item.id === record.id),
911
+ };
628
912
  },
629
913
 
630
914
  async removeSubscription({ id, keepInstalled = true }) {
@@ -646,55 +930,39 @@ export function createService({ paths, store, registry, cli, logger, env = proce
646
930
  assertWritable();
647
931
  const subscription = state().subscriptions.find((item) => item.id === id);
648
932
  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) {
659
- store.update((draft) => {
660
- const row = draft.subscriptions.find((item) => item.id === id);
661
- if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: result.error };
662
- });
663
- return { ok: false, code: 'error.skills.upstream-failed', message: result.error };
664
- }
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) };
933
+ let probe;
934
+ try {
935
+ probe = await readSnapshot(subscription, { force });
936
+ } catch (error) {
937
+ // 失败留痕(清单里能看到 lastCheck),但接口必须报错,不能把失败伪装成成功
938
+ if (!store.readOnly) {
939
+ store.update((draft) => {
940
+ const row = draft.subscriptions.find((item) => item.id === id);
941
+ if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: error.message };
942
+ });
943
+ }
944
+ throw error;
672
945
  }
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);
946
+ const at = nowIso();
690
947
  store.update((draft) => {
691
948
  const row = draft.subscriptions.find((item) => item.id === id);
692
949
  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' };
950
+ if (!row.ref && probe.branch) row.ref = probe.branch;
951
+ if (probe.notModified) {
952
+ // 304 只刷新时间,绝不把 entries 置空
953
+ row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
954
+ return;
955
+ }
956
+ row.snapshot = probe.snapshot;
957
+ row.lastCheck = { at, commit: probe.snapshot.commit, treeEtag: probe.snapshot.etag, status: probe.changed ? 'changed' : 'unchanged' };
695
958
  });
696
- logger('subscription.refresh', { id, entries: entries.length, changed });
697
- return { ok: true, changed, subscription: state().subscriptions.find((item) => item.id === id) };
959
+ if (!probe.notModified) logger('subscription.refresh', { id, entries: probe.snapshot.entries.length, changed: probe.changed });
960
+ return {
961
+ ok: true,
962
+ changed: !probe.notModified && probe.changed,
963
+ ...(probe.notModified ? { notModified: true } : {}),
964
+ subscription: state().subscriptions.find((item) => item.id === id),
965
+ };
698
966
  },
699
967
 
700
968
  async checkUpdates({ names, subscriptionId } = {}) {
@@ -708,16 +976,18 @@ export function createService({ paths, store, registry, cli, logger, env = proce
708
976
  for (const install of installs) {
709
977
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
710
978
  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);
979
+ // 按订阅(id 已含 source/ref/subpath)分组:不能用 `ref || 'main'` 当键,否则 null-ref 与 main 会撞车
980
+ if (!groups.has(subscription.id)) groups.set(subscription.id, { subscription, installs: [] });
981
+ groups.get(subscription.id).installs.push(install);
714
982
  }
715
983
 
716
984
  const results = [];
717
985
  for (const { subscription, installs: groupInstalls } of groups.values()) {
986
+ // 没写 ref 的订阅(旧版本/接管建的)按仓库默认分支检查,别硬套 main
987
+ const ref = (await branchOf(subscription, { persist: true })) || 'main';
718
988
  const tree = await fetchTree({
719
989
  source: subscription.source,
720
- ref: subscription.ref || 'main',
990
+ ref,
721
991
  etag: subscription.snapshot?.etag || null,
722
992
  token: tokenOf,
723
993
  fetchImpl,
@@ -779,9 +1049,10 @@ export function createService({ paths, store, registry, cli, logger, env = proce
779
1049
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
780
1050
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
781
1051
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
1052
+ const ref = (await branchOf(subscription)) || 'main';
782
1053
  const tree = await fetchTree({
783
1054
  source: subscription.source,
784
- ref: subscription.ref || 'main',
1055
+ ref,
785
1056
  token: tokenOf,
786
1057
  fetchImpl,
787
1058
  });
@@ -791,7 +1062,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
791
1062
  const entry = tree.tree.find((item) => item.type === 'blob' && item.path === skillPath);
792
1063
  if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
793
1064
  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 });
1065
+ const targetCommit = await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
795
1066
  const token = planToken({
796
1067
  subscriptionId: subscription.id,
797
1068
  from: install.commit || null,
@@ -806,6 +1077,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
806
1077
  token,
807
1078
  fromCommit: install.commit || null,
808
1079
  toCommit: targetCommit,
1080
+ ref,
809
1081
  targetTreeSha,
810
1082
  skillPath,
811
1083
  localModified: Boolean(install.installedHash) && contentHash(path.join(paths.canonical, name)) !== install.installedHash,
@@ -833,7 +1105,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
833
1105
  ? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
834
1106
  : null;
835
1107
 
836
- const sourceArg = plan.toCommit ? `${subscription.source}#${plan.toCommit}` : `${subscription.source}#${subscription.ref || 'main'}`;
1108
+ const sourceArg = plan.toCommit
1109
+ ? `${subscription.source}#${plan.toCommit}`
1110
+ : `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
837
1111
  const result = await cli.add({ source: sourceArg, skills: [name] });
838
1112
  const lock = readLock(paths);
839
1113
  const lockHash = lock.skills?.[name]?.skillFolderHash || null;
@@ -64,6 +64,18 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
64
64
  };
65
65
  }
66
66
 
67
+ /**
68
+ * 仓库默认分支。`ref` 缺省时以它为准:不少技能仓库默认是 `master`,
69
+ * 硬套 `main` 只会得到 404(例如 alchaincyf/huashu-design)。
70
+ */
71
+ export async function fetchDefaultBranch({ source, token, fetchImpl } = {}) {
72
+ 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;
77
+ }
78
+
67
79
  /** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
68
80
  export async function resolveCommit({ source, ref = 'main', token, fetchImpl } = {}) {
69
81
  const parsed = parseSource(source);
package/lib/routes.js CHANGED
@@ -1,9 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { ServiceError } from './core/service.js';
2
3
  import { StateReadOnlyError } from './core/state.js';
3
4
 
4
5
  export const ROUTE_PREFIX = '/api/morehao/skills-console';
5
6
  const MAX_BODY = 256 * 1024;
6
7
 
8
+ /**
9
+ * 插件身份:包名(去掉 scope,页头显示 dsh-skills-console)+ 版本。
10
+ * 从 package.json 读一次,随 /state 下发 —— 前端别硬编码版本,否则两处漂移。
11
+ */
12
+ export const PLUGIN_INFO = (() => {
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
+ const name = String(pkg.name || 'dsh-skills-console');
16
+ return {
17
+ name: name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name,
18
+ version: pkg.version || null,
19
+ repository: pkg.repository?.url || null,
20
+ };
21
+ } catch {
22
+ return { name: 'dsh-skills-console', version: null, repository: null };
23
+ }
24
+ })();
25
+
7
26
  function sendJson(res, status, payload) {
8
27
  const body = JSON.stringify(payload);
9
28
  res.writeHead(status, {
@@ -40,6 +59,7 @@ function matchRoute(method, pathname) {
40
59
  if (segments.length === 1 && segments[0] === 'settings' && method === 'POST') return { name: 'settings' };
41
60
  if (segments.length === 1 && segments[0] === 'links' && method === 'POST') return { name: 'links' };
42
61
  if (segments.length === 1 && segments[0] === 'install' && method === 'POST') return { name: 'install' };
62
+ if (segments.length === 1 && segments[0] === 'probe' && method === 'POST') return { name: 'probe' };
43
63
  if (segments.length === 1 && segments[0] === 'diagnostics' && method === 'GET') return { name: 'diagnostics' };
44
64
 
45
65
  if (segments[0] === 'skills' && segments.length === 2 && method === 'GET') {
@@ -123,8 +143,10 @@ export function createRoutes({ service, logger }) {
123
143
 
124
144
  async function dispatch(route, { body, query, service }) {
125
145
  switch (route.name) {
126
- case 'state':
127
- return service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
146
+ case 'state': {
147
+ const state = await service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
148
+ return { ...state, plugin: PLUGIN_INFO };
149
+ }
128
150
  case 'agents':
129
151
  return { ok: true, all: service.registry.all(), detected: service.registry.detected() };
130
152
  case 'settings':
@@ -133,6 +155,8 @@ async function dispatch(route, { body, query, service }) {
133
155
  return service.setLinks(body);
134
156
  case 'install':
135
157
  return service.install(body);
158
+ case 'probe':
159
+ return service.probeSource(body);
136
160
  case 'diagnostics':
137
161
  return service.diagnostics();
138
162
  case 'skill.body':