dsh-skill-hub 0.3.12 → 0.3.13

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/lib/index.js CHANGED
@@ -1123,6 +1123,39 @@ async function scanRoot(base) {
1123
1123
  function listSkillEntries(root, home = dshHome()) {
1124
1124
  return scanRoot(rootPath(root, home));
1125
1125
  }
1126
+ /**
1127
+ * Scan one skills root for hub-disabled discovery files: directory bundles
1128
+ * renamed to SKILL.md.disabled and flat <name>.md.disabled files. Used by
1129
+ * the startup reconcile to rebuild sidecar records that were lost, which
1130
+ * would otherwise leave the skill invisible in every view.
1131
+ */
1132
+ async function scanDisabledRoot(base) {
1133
+ const paths = [];
1134
+ let names;
1135
+ try {
1136
+ names = await readdir(base);
1137
+ } catch (error) {
1138
+ if (error.code === "ENOENT") return paths;
1139
+ throw error;
1140
+ }
1141
+ for (const name of names) {
1142
+ if (name.startsWith(".")) continue;
1143
+ const absolute = join(base, name);
1144
+ let stats;
1145
+ try {
1146
+ stats = await stat(absolute);
1147
+ } catch {
1148
+ continue;
1149
+ }
1150
+ if (stats.isDirectory()) {
1151
+ const candidate = join(absolute, "SKILL.md.disabled");
1152
+ try {
1153
+ if ((await stat(candidate)).isFile()) paths.push(candidate);
1154
+ } catch {}
1155
+ } else if (name.endsWith(".md.disabled") && name !== "SKILL.md.disabled") paths.push(absolute);
1156
+ }
1157
+ return paths.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1158
+ }
1126
1159
  /** Read UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
1127
1160
  async function readSkillInterface(directory) {
1128
1161
  const yamlPath = join(directory, "agents", "openai.yaml");
@@ -4401,6 +4434,62 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
4401
4434
  return reader;
4402
4435
  }
4403
4436
  //#endregion
4437
+ //#region src/reconcile.ts
4438
+ /**
4439
+ * Sidecar reconcile for hub-disabled skills.
4440
+ *
4441
+ * The catalog merges two sources: enabled skills come from the provider
4442
+ * (SKILL.md discovery), disabled skills come from the sidecar's `disabled`
4443
+ * records. The two can drift — a sidecar restored from a backup, a hand
4444
+ * edit, or an older build can leave a `SKILL.md.disabled` file on disk with
4445
+ * no record, which makes the skill invisible in every view (it is neither
4446
+ * enabled nor disabled) and leaves its origin collection rendering as an
4447
+ * empty shell. This walk rebuilds the missing records from disk at startup.
4448
+ */
4449
+ /**
4450
+ * Add sidecar disabled records for every `.disabled` discovery file that has
4451
+ * none yet. Existing records (matched by path or by name) win, so this never
4452
+ * rewrites user data; unreadable or invalid files are skipped (they surface
4453
+ * in the diagnostics scan instead). Returns the records added.
4454
+ */
4455
+ async function reconcileDisabledSkills(store, home = dshHome()) {
4456
+ const known = await store.listDisabled();
4457
+ const knownNames = new Set(known.map((entry) => entry.name));
4458
+ const knownPaths = new Set(known.map((entry) => entry.path));
4459
+ const added = [];
4460
+ for (const root of WRITABLE_ROOTS) for (const path of await scanDisabledRoot(rootPath(root, home))) {
4461
+ if (knownPaths.has(path)) continue;
4462
+ let text;
4463
+ try {
4464
+ text = await readFile(path, "utf8");
4465
+ } catch {
4466
+ continue;
4467
+ }
4468
+ const parsed = parseFrontmatter(text);
4469
+ if ("error" in parsed) continue;
4470
+ const { name, description } = parsed.value;
4471
+ if (knownNames.has(name)) continue;
4472
+ let disabledAt = 0;
4473
+ try {
4474
+ disabledAt = (await stat(path)).mtimeMs;
4475
+ } catch {
4476
+ continue;
4477
+ }
4478
+ const record = {
4479
+ name,
4480
+ description,
4481
+ path,
4482
+ root,
4483
+ disabledAt
4484
+ };
4485
+ await store.addDisabled(record);
4486
+ knownNames.add(name);
4487
+ knownPaths.add(path);
4488
+ added.push(record);
4489
+ }
4490
+ return added;
4491
+ }
4492
+ //#endregion
4404
4493
  //#region src/index.ts
4405
4494
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
4406
4495
  const name = "skill-hub";
@@ -4527,6 +4616,12 @@ function apply(ctx, config) {
4527
4616
  } catch (error) {
4528
4617
  ctx.logger.warn("[dsh-skill-hub] startup cleanup failed", error);
4529
4618
  }
4619
+ try {
4620
+ const reconciled = await reconcileDisabledSkills(store, home);
4621
+ if (reconciled.length > 0) ctx.logger.info(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(", ")}`);
4622
+ } catch (error) {
4623
+ ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
4624
+ }
4530
4625
  })();
4531
4626
  (async () => {
4532
4627
  try {
@@ -12,7 +12,7 @@
12
12
  * Closing a group whose member is enabled in another group is a conflict the
13
13
  * GUI resolves with a dialog; the helpers below compute both sides.
14
14
  */
15
- import type { CatalogSkill, CollectionGroup, SkillTag } from '../protocol.ts';
15
+ import type { CatalogSkill, CollectionGroup, DisabledSkill, SkillTag } from '../protocol.ts';
16
16
  import { isProjectSource } from '../protocol.ts';
17
17
  export { isProjectSource };
18
18
  /** Grouped switch state derived from member enablement. */
@@ -50,6 +50,23 @@ export declare const PRIVATE_SOURCE = "private";
50
50
  * 项目级技能(有 workspace 归属)永远不算「个人」。
51
51
  */
52
52
  export declare function filterBySource(skills: readonly CatalogSkill[], source: string, origins: Readonly<Record<string, string>>): CatalogSkill[];
53
+ /** One origin collection with the members visible under the current filters. */
54
+ export interface VisibleCollection {
55
+ collection: CollectionGroup;
56
+ /** Enabled, currently visible members. */
57
+ skills: CatalogSkill[];
58
+ /** Disabled records passing the current name/description filter. */
59
+ disabledMembers: DisabledSkill[];
60
+ }
61
+ /**
62
+ * Match origin collections against the currently visible enabled skills and
63
+ * disabled records, dropping collections with no visible member. Without
64
+ * this, a stale origin (skill deleted on disk, or a `.disabled` file whose
65
+ * sidecar record was lost) renders a group header with zero rows — an empty
66
+ * shell the sources tab otherwise never shows (project/personal groups
67
+ * already disappear when they have nothing to display).
68
+ */
69
+ export declare function visibleCollections(collections: readonly CollectionGroup[], visibleSkills: readonly CatalogSkill[], disabledRecords: readonly DisabledSkill[], normalized: string, sourceFilter: string, origins: Readonly<Record<string, string>>): VisibleCollection[];
53
70
  /** Catalog sort keys offered by the filter bar. */
54
71
  export type SortKey = 'name' | 'added' | 'uses';
55
72
  /**
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Sidecar reconcile for hub-disabled skills.
3
+ *
4
+ * The catalog merges two sources: enabled skills come from the provider
5
+ * (SKILL.md discovery), disabled skills come from the sidecar's `disabled`
6
+ * records. The two can drift — a sidecar restored from a backup, a hand
7
+ * edit, or an older build can leave a `SKILL.md.disabled` file on disk with
8
+ * no record, which makes the skill invisible in every view (it is neither
9
+ * enabled nor disabled) and leaves its origin collection rendering as an
10
+ * empty shell. This walk rebuilds the missing records from disk at startup.
11
+ */
12
+ import type { DisabledSkill } from './protocol.ts';
13
+ /** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
14
+ export interface DisabledReconcileStore {
15
+ listDisabled(): Promise<DisabledSkill[]>;
16
+ addDisabled(entry: DisabledSkill): Promise<void>;
17
+ }
18
+ /**
19
+ * Add sidecar disabled records for every `.disabled` discovery file that has
20
+ * none yet. Existing records (matched by path or by name) win, so this never
21
+ * rewrites user data; unreadable or invalid files are skipped (they surface
22
+ * in the diagnostics scan instead). Returns the records added.
23
+ */
24
+ export declare function reconcileDisabledSkills(store: DisabledReconcileStore, home?: string): Promise<DisabledSkill[]>;
@@ -15,6 +15,13 @@ export interface SkillEntry {
15
15
  export declare function scanRoot(base: string): Promise<SkillEntry[]>;
16
16
  /** Scan one writable root. */
17
17
  export declare function listSkillEntries(root: WritableRoot, home?: string): Promise<SkillEntry[]>;
18
+ /**
19
+ * Scan one skills root for hub-disabled discovery files: directory bundles
20
+ * renamed to SKILL.md.disabled and flat <name>.md.disabled files. Used by
21
+ * the startup reconcile to rebuild sidecar records that were lost, which
22
+ * would otherwise leave the skill invisible in every view.
23
+ */
24
+ export declare function scanDisabledRoot(base: string): Promise<string[]>;
18
25
  /** UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
19
26
  export interface SkillInterface {
20
27
  displayName?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -72,7 +72,8 @@
72
72
  "0.1.5-alpha.1",
73
73
  "0.1.5-alpha.2",
74
74
  "0.1.5-rc.1",
75
- "0.1.5-rc.2"
75
+ "0.1.5-rc.2",
76
+ "0.1.6-alpha.1"
76
77
  ]
77
78
  },
78
79
  "capability": {
@@ -94,19 +95,19 @@
94
95
  },
95
96
  "peerDependencies": {
96
97
  "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2",
97
- "@deepseek-ai/dsh-client-connection": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
98
- "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
99
- "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
100
- "@deepseek-ai/dsh-client-store": ">=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
101
- "@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
102
- "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
103
- "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
104
- "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
105
- "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
106
- "@deepseek-ai/dsh-session-query": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
107
- "@deepseek-ai/dsh-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
108
- "@deepseek-ai/dsh-skill": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
109
- "@deepseek-ai/dsh-system-prompt": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0",
98
+ "@deepseek-ai/dsh-client-connection": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
99
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
100
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
101
+ "@deepseek-ai/dsh-client-store": ">=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
102
+ "@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
103
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
104
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
105
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
106
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
107
+ "@deepseek-ai/dsh-session-query": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
108
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
109
+ "@deepseek-ai/dsh-skill": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
110
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
110
111
  "react": "^18.2.0",
111
112
  "react-dom": "^18.2.0"
112
113
  },
@@ -120,19 +121,19 @@
120
121
  },
121
122
  "devDependencies": {
122
123
  "@deepseek-ai/cordis": "^4.0.2",
123
- "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.2",
124
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
125
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.2",
126
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.5-rc.2",
127
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
128
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
129
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
130
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
131
- "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
132
- "@deepseek-ai/dsh-session-query": "^0.1.5-rc.2",
133
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
134
- "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
135
- "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
124
+ "@deepseek-ai/dsh-client-connection": "^0.1.6-alpha.1",
125
+ "@deepseek-ai/dsh-client-locale": "^0.1.6-alpha.1",
126
+ "@deepseek-ai/dsh-client-store": "^0.1.6-alpha.1",
127
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.6-alpha.1",
128
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.6-alpha.1",
129
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.6-alpha.1",
130
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.6-alpha.1",
131
+ "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.1",
132
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
133
+ "@deepseek-ai/dsh-session-query": "^0.1.6-alpha.1",
134
+ "@deepseek-ai/dsh-settings": "^0.1.6-alpha.1",
135
+ "@deepseek-ai/dsh-skill": "^0.1.6-alpha.1",
136
+ "@deepseek-ai/dsh-system-prompt": "^0.1.6-alpha.1",
136
137
  "@types/js-yaml": "^4.0.9",
137
138
  "@types/node": "^22.20.0",
138
139
  "@types/react": "~18.3.1",
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import type { CatalogSkill, SkillTag } from '../protocol.ts'
3
- import { conflictsOnClose, filterBySource, formatRelativeTime, groupNamesOf, groupSwitchView, PRIVATE_SOURCE, sortSkills } from './grouping.ts'
2
+ import type { CatalogSkill, CollectionGroup, DisabledSkill, SkillTag } from '../protocol.ts'
3
+ import { conflictsOnClose, filterBySource, formatRelativeTime, groupNamesOf, groupSwitchView, PRIVATE_SOURCE, sortSkills, visibleCollections } from './grouping.ts'
4
4
 
5
5
  function skill(name: string, writable = true): CatalogSkill {
6
6
  return {
@@ -75,6 +75,39 @@ describe('filterBySource', () => {
75
75
  })
76
76
  })
77
77
 
78
+ describe('visibleCollections', () => {
79
+ const collections: CollectionGroup[] = [
80
+ { name: 'repo/x', skillNames: ['enabled-one', 'disabled-one'] },
81
+ { name: 'repo/ghost', skillNames: ['gone'] },
82
+ ]
83
+ const disabledOne: DisabledSkill = { name: 'disabled-one', description: 'Paused skill', path: '/x/disabled-one/SKILL.md.disabled', root: 'user-dsh', disabledAt: 1 }
84
+ const origins = { 'enabled-one': 'repo/x', 'disabled-one': 'repo/x' }
85
+
86
+ it('keeps collections with visible members and drops empty shells', () => {
87
+ const visible = visibleCollections(collections, [skill('enabled-one')], [disabledOne], '', 'all', origins)
88
+ expect(visible.map((entry) => entry.collection.name)).toEqual(['repo/x'])
89
+ expect(visible[0].skills.map((s) => s.name)).toEqual(['enabled-one'])
90
+ expect(visible[0].disabledMembers.map((d) => d.name)).toEqual(['disabled-one'])
91
+ })
92
+
93
+ it('drops a collection when neither side passes the search filter', () => {
94
+ expect(visibleCollections(collections, [], [disabledOne], 'no-match', 'all', origins)).toEqual([])
95
+ expect(visibleCollections(collections, [], [disabledOne], 'paused', 'all', origins).map((e) => e.collection.name)).toEqual(['repo/x'])
96
+ })
97
+
98
+ it('applies the source filter to disabled-only collections', () => {
99
+ expect(visibleCollections(collections, [], [disabledOne], '', 'repo/other', origins)).toEqual([])
100
+ expect(visibleCollections(collections, [], [disabledOne], '', 'private', origins)).toEqual([])
101
+ expect(visibleCollections(collections, [], [disabledOne], '', 'repo/x', origins).map((e) => e.collection.name)).toEqual(['repo/x'])
102
+ })
103
+
104
+ it('treats a disabled record with no origin as private', () => {
105
+ const privateRecord = { ...disabledOne, name: 'private-one' }
106
+ const privateCollection: CollectionGroup = { name: 'repo/y', skillNames: ['private-one'] }
107
+ expect(visibleCollections([privateCollection], [], [privateRecord], '', PRIVATE_SOURCE, {}).map((e) => e.collection.name)).toEqual(['repo/y'])
108
+ })
109
+ })
110
+
78
111
  describe('sortSkills', () => {
79
112
  it('sorts by name ascending', () => {
80
113
  const skills = [skill('zeta'), skill('alpha'), skill('beta')]
@@ -13,7 +13,7 @@
13
13
  * GUI resolves with a dialog; the helpers below compute both sides.
14
14
  */
15
15
 
16
- import type { CatalogSkill, CollectionGroup, SkillTag } from '../protocol.ts'
16
+ import type { CatalogSkill, CollectionGroup, DisabledSkill, SkillTag } from '../protocol.ts'
17
17
  import { isProjectSource } from '../protocol.ts'
18
18
 
19
19
  export { isProjectSource }
@@ -87,6 +87,43 @@ export function filterBySource(skills: readonly CatalogSkill[], source: string,
87
87
  })
88
88
  }
89
89
 
90
+ /** One origin collection with the members visible under the current filters. */
91
+ export interface VisibleCollection {
92
+ collection: CollectionGroup
93
+ /** Enabled, currently visible members. */
94
+ skills: CatalogSkill[]
95
+ /** Disabled records passing the current name/description filter. */
96
+ disabledMembers: DisabledSkill[]
97
+ }
98
+
99
+ /**
100
+ * Match origin collections against the currently visible enabled skills and
101
+ * disabled records, dropping collections with no visible member. Without
102
+ * this, a stale origin (skill deleted on disk, or a `.disabled` file whose
103
+ * sidecar record was lost) renders a group header with zero rows — an empty
104
+ * shell the sources tab otherwise never shows (project/personal groups
105
+ * already disappear when they have nothing to display).
106
+ */
107
+ export function visibleCollections(
108
+ collections: readonly CollectionGroup[],
109
+ visibleSkills: readonly CatalogSkill[],
110
+ disabledRecords: readonly DisabledSkill[],
111
+ normalized: string,
112
+ sourceFilter: string,
113
+ origins: Readonly<Record<string, string>>,
114
+ ): VisibleCollection[] {
115
+ const visible: VisibleCollection[] = []
116
+ for (const collection of collections) {
117
+ const skills = visibleSkills.filter((skill) => collection.skillNames.includes(skill.name))
118
+ const disabledMembers = disabledRecords.filter((record) =>
119
+ collection.skillNames.includes(record.name)
120
+ && (normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
121
+ && (sourceFilter === 'all' || (origins[record.name] ?? PRIVATE_SOURCE) === sourceFilter))
122
+ if (skills.length > 0 || disabledMembers.length > 0) visible.push({ collection, skills, disabledMembers })
123
+ }
124
+ return visible
125
+ }
126
+
90
127
  /** Catalog sort keys offered by the filter bar. */
91
128
  export type SortKey = 'name' | 'added' | 'uses'
92
129
 
@@ -101,8 +101,14 @@ export function PanelDialogs(props: PanelDialogsProps): JSX.Element {
101
101
  <VersionChoiceDialog
102
102
  choice={versionDialog}
103
103
  busy={versionBusy}
104
- onSelect={(selected) => { setVersionDialog({ ...versionDialog, selected }) }}
105
- onCustom={(custom) => { setVersionDialog({ ...versionDialog, custom }) }}
104
+ // Keep the selected ref and custom override in one functional
105
+ // update, so a ref selection cannot be lost to a stale snapshot.
106
+ onSelect={(selected) => {
107
+ setVersionDialog((previous) => previous === null ? previous : { ...previous, selected, custom: '' })
108
+ }}
109
+ onCustom={(custom) => {
110
+ setVersionDialog((previous) => previous === null ? previous : { ...previous, custom })
111
+ }}
106
112
  onCancel={() => { setVersionDialog(null) }}
107
113
  onConfirm={() => { void confirmVersionDialog() }}
108
114
  />
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useMemo, useState, type JSX } from 'react'
10
10
  import { tt } from '../helpers.ts'
11
- import { filterBySource, groupSwitchView, isProjectSource, PRIVATE_SOURCE } from '../grouping.ts'
11
+ import { filterBySource, groupSwitchView, isProjectSource, PRIVATE_SOURCE, visibleCollections } from '../grouping.ts'
12
12
  import { SkillRow } from './SkillRow.tsx'
13
13
  import { DisabledRow } from './DisabledRow.tsx'
14
14
  import { GroupSummary } from './GroupSummary.tsx'
@@ -28,10 +28,14 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
28
28
  const duplicateNames = useMemo(() => new Set(catalog?.duplicateNames ?? []), [catalog])
29
29
 
30
30
  // ----- 顶层分组统一拖拽(project / col:xxx / personal 全部可拖) -----
31
- const projectSkillsAll = filterBySource(sorted, sourceFilter, origins).filter((skill) => isProjectSource(skill.source))
31
+ const sourceFiltered = filterBySource(sorted, sourceFilter, origins)
32
+ const projectSkillsAll = sourceFiltered.filter((skill) => isProjectSource(skill.source))
32
33
  const hasProject = projectSkillsAll.length > 0
33
- const collections = groupsState?.collections ?? []
34
- const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
34
+ // 无可见成员的来源组不渲染:来源记录指向的技能可能已被删除,或禁用记录
35
+ // 丢失导致技能既非启用也非禁用,留下一个组头有数字、展开 0 行的空壳。
36
+ const visible = visibleCollections(groupsState?.collections ?? [], sourceFiltered, catalog?.disabled ?? [], normalized, sourceFilter, origins)
37
+ const collections = visible.map((entry) => entry.collection)
38
+ const uncategorized = sourceFiltered.filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
35
39
  const personalDisabled = (catalog?.disabled ?? []).filter((record) => origins[record.name] === undefined)
36
40
  .filter((record) => normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
37
41
  .filter((record) => sourceFilter === 'all' || sourceFilter === PRIVATE_SOURCE)
@@ -79,7 +83,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
79
83
  const rowProps = { uses: hub.uses, hubConfig: hub.hubConfig, busyNames, editMode: hub.editMode, tagBusy: hub.tagBusy, duplicateNames, toggle: hub.toggle, openDetail: hub.openDetail, requestDeleteSkill: hub.requestDeleteSkill }
80
84
 
81
85
  if (skillView === 'flat') {
82
- return <>{filterBySource(sorted, sourceFilter, origins).map((skill) => <SkillRow key={skill.name} skill={skill} {...rowProps} />)}</>
86
+ return <>{sourceFiltered.map((skill) => <SkillRow key={skill.name} skill={skill} {...rowProps} />)}</>
83
87
  }
84
88
 
85
89
  // 空状态:没有任何分组时提示
@@ -117,13 +121,9 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
117
121
  // Collection 卡片(可拖,归属顶层排序)
118
122
  if (topKey.startsWith('col:')) {
119
123
  const colName = topKey.slice(4)
120
- const collection = collections.find((c) => c.name === colName)
121
- if (collection === undefined) return null
122
- const skills = filterBySource(sorted, sourceFilter, origins).filter((skill) => collection.skillNames.includes(skill.name))
123
- const disabledMembers = (catalog?.disabled ?? []).filter((record) =>
124
- collection.skillNames.includes(record.name)
125
- && (normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
126
- && (sourceFilter === 'all' || (origins[record.name] ?? PRIVATE_SOURCE) === sourceFilter))
124
+ const entry = visible.find((item) => item.collection.name === colName)
125
+ if (entry === undefined) return null
126
+ const { collection, skills, disabledMembers } = entry
127
127
  const collapsed = collapsedGroups.has('col:' + collection.name)
128
128
  const view = groupSwitchView(collection.skillNames, viewNames)
129
129
  const check = sourceCheck[collection.name]
@@ -5,7 +5,7 @@
5
5
  * the same role/aria shell; the panel owns all dialog state.
6
6
  */
7
7
 
8
- import { useEffect, type JSX, type ReactNode } from 'react'
8
+ import { useEffect, useState, type JSX, type ReactNode } from 'react'
9
9
  import type { CollectionGroup, SkillTag } from '../../protocol.ts'
10
10
  import { tt } from '../helpers.ts'
11
11
  import { groupNamesOf } from '../grouping.ts'
@@ -170,29 +170,56 @@ export function VersionChoiceDialog(props: {
170
170
  }): JSX.Element {
171
171
  const { choice, busy, onSelect, onCustom, onCancel, onConfirm } = props
172
172
  const effective = choice.custom.trim() !== '' ? choice.custom.trim() : choice.selected
173
+ type RefGroup = 'releases' | 'branches'
174
+ const branchOptions = choice.branches.filter((branch) => !choice.releases.includes(branch))
175
+ const [refGroup, setRefGroup] = useState<RefGroup>(
176
+ choice.releases.includes(choice.selected) || choice.branches.length === 0 ? 'releases' : 'branches',
177
+ )
178
+ // The dialog starts in a loading state and receives the release/branch
179
+ // lists in a later render. Align the group once that data arrives, without
180
+ // resetting it after the user selects another ref.
181
+ useEffect(() => {
182
+ if (choice.loading) return
183
+ if (choice.releases.includes(choice.selected)) setRefGroup('releases')
184
+ else if (branchOptions.includes(choice.selected)) setRefGroup('branches')
185
+ }, [choice.loading, choice.repo])
186
+ const activeRefs = refGroup === 'releases' ? choice.releases : branchOptions
187
+ const listedRefs = [...choice.releases, ...branchOptions]
188
+ const currentUnlistedRef = choice.selected !== '' && !listedRefs.includes(choice.selected) ? choice.selected : undefined
189
+ const selectRefs = currentUnlistedRef !== undefined ? [currentUnlistedRef, ...activeRefs] : activeRefs
190
+ const selectValue = selectRefs.includes(choice.selected) ? choice.selected : selectRefs[0] ?? ''
191
+ const selectGroup = (next: RefGroup): void => {
192
+ setRefGroup(next)
193
+ const nextRefs = next === 'releases' ? choice.releases : branchOptions
194
+ if (nextRefs.length > 0 && !nextRefs.includes(choice.selected)) onSelect(nextRefs[0])
195
+ }
173
196
  return (
174
197
  <DialogShell onClose={onCancel}>
175
198
  <h3 className={css.dialogTitle}>{tt('market.versionTitle')}</h3>
176
199
  <p className={css.dialogText}>{tt('market.versionText', { repo: choice.repo })}{choice.current !== undefined ? ` (${tt('market.versionCurrent', { ref: choice.current })})` : ''}</p>
177
200
  {choice.loading ? <p className={css.dialogText}>{tt('market.scanning')}</p> : (
178
201
  <>
179
- {choice.releases.length > 0 ? (
180
- <>
181
- <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionReleases')}</p>
182
- <select className={css.select + ' ' + css.dialogSelect} value={choice.releases.includes(choice.selected) ? choice.selected : choice.releases[0]}
183
- onChange={(event) => { onSelect(event.target.value); onCustom('') }}>
184
- {choice.releases.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
185
- </select>
186
- </>
187
- ) : null}
188
- {choice.branches.length > 0 ? (
189
- <>
190
- <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionBranches')}</p>
191
- <select className={css.select + ' ' + css.dialogSelect} value={choice.branches.includes(choice.selected) ? choice.selected : choice.branches[0]}
192
- onChange={(event) => { onSelect(event.target.value); onCustom('') }}>
193
- {choice.branches.map((branch) => <option key={branch} value={branch}>{branch}</option>)}
194
- </select>
195
- </>
202
+ <div className={css.segmented} role='group' aria-label={tt('market.versionTitle')} style={{ marginBottom: 8 }}>
203
+ <button
204
+ type='button'
205
+ className={css.segBtn + (refGroup === 'releases' ? ' ' + css.segBtnActive : '')}
206
+ disabled={choice.releases.length === 0}
207
+ aria-pressed={refGroup === 'releases'}
208
+ onClick={() => { selectGroup('releases') }}
209
+ >{tt('market.versionReleases')}</button>
210
+ <button
211
+ type='button'
212
+ className={css.segBtn + (refGroup === 'branches' ? ' ' + css.segBtnActive : '')}
213
+ disabled={branchOptions.length === 0}
214
+ aria-pressed={refGroup === 'branches'}
215
+ onClick={() => { selectGroup('branches') }}
216
+ >{tt('market.versionBranches')}</button>
217
+ </div>
218
+ {selectValue !== '' ? (
219
+ <select className={css.select + ' ' + css.dialogSelect} value={selectValue}
220
+ onChange={(event) => { onSelect(event.target.value) }}>
221
+ {selectRefs.map((ref) => <option key={ref} value={ref}>{ref}</option>)}
222
+ </select>
196
223
  ) : null}
197
224
  <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionCustom')}</p>
198
225
  <input className={css.input + ' ' + css.dialogSelect} value={choice.custom}
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { SkillHubProvider } from './provider.ts'
21
21
  import { makeRoutes } from './routes.ts'
22
22
  import { createSkillStatsReader, asPersistenceSeam, type SessionPersistenceLike, type SessionQueryLike, type SkillStatsReader } from './stats.ts'
23
23
  import { SkillHubStore } from './store.ts'
24
+ import { reconcileDisabledSkills } from './reconcile.ts'
24
25
  import { cleanupLeftoverImportDirs, setGithubToken } from './repo.ts'
25
26
  import { dshHome } from './store.ts'
26
27
  import { join } from 'node:path'
@@ -234,6 +235,16 @@ export function apply(ctx: Context, config?: Config): void {
234
235
  ctx.logger.warn('[dsh-skill-hub] startup cleanup failed', error)
235
236
  }
236
237
  }
238
+ // 对账:磁盘上已有 .disabled、sidecar 却无记录(状态文件被恢复/手改、旧版本
239
+ // 遗留)时补记录,否则这些技能在面板里既不算启用也不算禁用,来源组空壳。
240
+ try {
241
+ const reconciled = await reconcileDisabledSkills(store, home)
242
+ if (reconciled.length > 0) {
243
+ ctx.logger.info(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(', ')}`)
244
+ }
245
+ } catch (error) {
246
+ ctx.logger.warn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
247
+ }
237
248
  })()
238
249
 
239
250
  void (async () => {