dsh-skill-hub 0.3.17 → 0.3.19

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
@@ -15,13 +15,6 @@ import { randomUUID } from "node:crypto";
15
15
  */
16
16
  const SKILL_HUB_API_ROOT = "/api/skill-hub";
17
17
  /**
18
- * Deprecated path of the market update check, kept routable for one release
19
- * after the naming unification below. A browser tab that loaded the previous
20
- * client bundle keeps calling it until it reloads; delete this (and its route
21
- * in routes/market.ts) in the next minor.
22
- */
23
- const SKILL_HUB_API_DEPRECATED_MARKET_CHECK = "/api/skill-hub/market/check";
24
- /**
25
18
  * Browser-facing base paths of the skill-hub API family.
26
19
  *
27
20
  * Naming rule: a path's segments mirror its scope. Market sources own the
@@ -2140,7 +2133,7 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
2140
2133
  }
2141
2134
  if (response === null || !response.ok) {
2142
2135
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
2143
- const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`;
2136
+ const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`;
2144
2137
  try {
2145
2138
  response = await fetchImpl(apiUrl, {
2146
2139
  headers: {
@@ -2546,10 +2539,18 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2546
2539
  * routes 共享层 · 目录装配:已知工作区清单、完整目录响应(时间戳/interface
2547
2540
  * 元数据/重名诊断)与详情映射。从 helpers.ts 原样搬出,行为不变。
2548
2541
  */
2549
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
2550
- async function knownSkillNames(deps) {
2542
+ /**
2543
+ * 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
2544
+ * 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
2545
+ *
2546
+ * 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
2547
+ * 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
2548
+ * (issue #16)。
2549
+ */
2550
+ async function knownSkillNames(deps, options) {
2551
2551
  const snapshot = await deps.skills.snapshot();
2552
2552
  const names = new Set(snapshot.skills.map((skill) => skill.name));
2553
+ if (options?.includeDisabled === false) return names;
2553
2554
  for (const disabled of await deps.store.listDisabled()) names.add(disabled.name);
2554
2555
  return names;
2555
2556
  }
@@ -3434,11 +3435,6 @@ function marketRoutes(deps) {
3434
3435
  methods: ["GET"],
3435
3436
  handler: checkMarketSources
3436
3437
  },
3437
- {
3438
- path: SKILL_HUB_API_DEPRECATED_MARKET_CHECK,
3439
- methods: ["GET"],
3440
- handler: checkMarketSources
3441
- },
3442
3438
  {
3443
3439
  path: SKILL_HUB_API.marketStats,
3444
3440
  methods: ["GET"],
@@ -3543,6 +3539,34 @@ function marketRoutes(deps) {
3543
3539
  * 仓库导入域路由:repo 发现 / repoImport 异步导入任务 / progress 轮询 /
3544
3540
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3545
3541
  */
3542
+ /**
3543
+ * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
3544
+ * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
3545
+ * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
3546
+ *
3547
+ * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
3548
+ * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
3549
+ */
3550
+ async function clearDisabledPlaceholder(deps, targetRoot, name) {
3551
+ const record = await deps.store.getDisabled(name);
3552
+ if (record === void 0) return false;
3553
+ const dir = join(targetRoot, name);
3554
+ const sourceToTrash = record.path.endsWith("SKILL.md.disabled") ? dirname(record.path) : record.path;
3555
+ if (sourceToTrash !== dir) return false;
3556
+ if (await pathExists(join(dir, "SKILL.md"))) return false;
3557
+ const trashDir = join(targetRoot, ".trash");
3558
+ await mkdir(trashDir, { recursive: true });
3559
+ const target = join(trashDir, name + "-" + Date.now());
3560
+ await rename(sourceToTrash, target);
3561
+ await deps.store.addTrash({
3562
+ name,
3563
+ path: target,
3564
+ movedAt: Date.now(),
3565
+ sourcePath: sourceToTrash
3566
+ });
3567
+ await deps.store.removeDisabled(name);
3568
+ return true;
3569
+ }
3546
3570
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3547
3571
  function repoImportRoutes(deps) {
3548
3572
  return [
@@ -3588,7 +3612,7 @@ function repoImportRoutes(deps) {
3588
3612
  ok: true,
3589
3613
  repo,
3590
3614
  ref: resolvedRef,
3591
- entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps)),
3615
+ entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps, { includeDisabled: false })),
3592
3616
  ...truncated ? { truncated: true } : {}
3593
3617
  });
3594
3618
  }
@@ -3612,7 +3636,7 @@ function repoImportRoutes(deps) {
3612
3636
  const repo = repoSlug(parsed);
3613
3637
  const requestedRef = readString(body, "ref");
3614
3638
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, requestedRef !== "" ? requestedRef : parsed.ref);
3615
- const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps));
3639
+ const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps, { includeDisabled: false }));
3616
3640
  const byPath = new Map(entries.map((entry) => [entry.path, entry]));
3617
3641
  const selected = paths.map((path) => byPath.get(path)).filter((entry) => entry !== void 0);
3618
3642
  if (selected.length === 0) {
@@ -3645,6 +3669,7 @@ function repoImportRoutes(deps) {
3645
3669
  imported: [],
3646
3670
  skipped: [],
3647
3671
  failed: [],
3672
+ replaced: [],
3648
3673
  status: "running",
3649
3674
  controller,
3650
3675
  createdAt: now
@@ -3671,13 +3696,22 @@ function repoImportRoutes(deps) {
3671
3696
  continue;
3672
3697
  }
3673
3698
  if (await pathExists(join(targetRoot, entry.name))) {
3674
- job.skipped.push({
3675
- name: entry.name,
3676
- reason: "exists"
3677
- });
3678
- job.downloadedBytes += entry.totalBytes;
3679
- job.done += 1;
3680
- continue;
3699
+ let cleared = false;
3700
+ try {
3701
+ cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
3702
+ } catch {
3703
+ cleared = false;
3704
+ }
3705
+ if (!cleared) {
3706
+ job.skipped.push({
3707
+ name: entry.name,
3708
+ reason: "exists"
3709
+ });
3710
+ job.downloadedBytes += entry.totalBytes;
3711
+ job.done += 1;
3712
+ continue;
3713
+ }
3714
+ job.replaced.push(entry.name);
3681
3715
  }
3682
3716
  if (controller.signal.aborted) break;
3683
3717
  const files = collectRepoSkillFiles(tree, entry.dir);
@@ -3760,6 +3794,7 @@ function repoImportRoutes(deps) {
3760
3794
  imported: [...job.imported],
3761
3795
  skipped: [...job.skipped],
3762
3796
  failed: [...job.failed],
3797
+ ...job.replaced.length > 0 ? { replaced: [...job.replaced] } : {},
3763
3798
  ...job.error !== void 0 ? { error: job.error } : {}
3764
3799
  });
3765
3800
  }
@@ -70,6 +70,7 @@ export declare const zhMarket: {
70
70
  readonly 'repo.files': "{count} 个文件 · {size}";
71
71
  readonly 'repo.imported': "导入成功 {count} 个";
72
72
  readonly 'repo.skippedExisting': "跳过已存在 {count} 个";
73
+ readonly 'repo.replaced': "已替换禁用占位 {count} 个(旧目录已移入回收站)";
73
74
  readonly 'repo.failed': "失败 {count} 个";
74
75
  readonly 'repo.cancel': "取消";
75
76
  readonly 'repo.cancelled': "已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理";
@@ -153,6 +153,7 @@ export declare const zh: {
153
153
  readonly 'repo.files': "{count} 个文件 · {size}";
154
154
  readonly 'repo.imported': "导入成功 {count} 个";
155
155
  readonly 'repo.skippedExisting': "跳过已存在 {count} 个";
156
+ readonly 'repo.replaced': "已替换禁用占位 {count} 个(旧目录已移入回收站)";
156
157
  readonly 'repo.failed': "失败 {count} 个";
157
158
  readonly 'repo.cancel': "取消";
158
159
  readonly 'repo.cancelled': "已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理";
@@ -5,13 +5,6 @@
5
5
  * reads like an auth problem).
6
6
  */
7
7
  export declare const SKILL_HUB_API_ROOT = "/api/skill-hub";
8
- /**
9
- * Deprecated path of the market update check, kept routable for one release
10
- * after the naming unification below. A browser tab that loaded the previous
11
- * client bundle keeps calling it until it reloads; delete this (and its route
12
- * in routes/market.ts) in the next minor.
13
- */
14
- export declare const SKILL_HUB_API_DEPRECATED_MARKET_CHECK = "/api/skill-hub/market/check";
15
8
  /**
16
9
  * Browser-facing base paths of the skill-hub API family.
17
10
  *
@@ -96,6 +96,12 @@ export interface RepoImportProgressResponse {
96
96
  name: string;
97
97
  error: string;
98
98
  }>;
99
+ /**
100
+ * 本次导入中「让位的已禁用同名占位」名字(issue #16):这些条目的旧目录已
101
+ * 移入回收站,新技能随后落地。旧目录挪走即计入,下载失败时也在列。老客户端
102
+ * 不认这个字段时忽略即可,故为可选。
103
+ */
104
+ replaced?: string[];
99
105
  /** error 状态时的原因 */
100
106
  error?: string;
101
107
  }
@@ -7,7 +7,7 @@
7
7
  * 按域拆分后的重导出入口:老 `from './protocol.ts'` 写法保持可用,
8
8
  * 新代码可按需直引 `from './protocol/<domain>.ts'`。
9
9
  */
10
- export { SKILL_HUB_API, SKILL_HUB_API_ROOT, SKILL_HUB_API_DEPRECATED_MARKET_CHECK } from './protocol/api.ts';
10
+ export { SKILL_HUB_API, SKILL_HUB_API_ROOT } from './protocol/api.ts';
11
11
  export type { WritableRoot, HubInvocation, CatalogSkill, DisabledSkill, DiagnosticEntry, DiagnosticFixRequest, DiagnosticFixResponse, CatalogResponse, SkillDetail, SkillDetailResponse, SkillDeleteRequest, SkillDeleteResponse, ToggleRequest, ToggleResponse, ToggleBatchRequest, ToggleBatchResponse, CreateRequest, CreateResponse, } from './protocol/catalog.ts';
12
12
  export type { SkillStat, StatsResponse, SkillStatsCheckpoint } from './protocol/stats.ts';
13
13
  export type { ErrorResponse, HubConfig, HubSettingsValue, RedactedHubConfig, ConfigResponse, ConfigRequest } from './protocol/config.ts';
@@ -6,8 +6,17 @@ import type { SkillDefinition } from '@deepseek-ai/dsh-skill';
6
6
  import { type CatalogResponse, type SkillDetail } from '../protocol.ts';
7
7
  import { type SkillInterface } from '../skillfs.ts';
8
8
  import { type SkillHubRouteDeps } from './deps.ts';
9
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
10
- export declare function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>>;
9
+ /**
10
+ * 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
11
+ * 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
12
+ *
13
+ * 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
14
+ * 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
15
+ * (issue #16)。
16
+ */
17
+ export declare function knownSkillNames(deps: SkillHubRouteDeps, options?: {
18
+ includeDisabled?: boolean;
19
+ }): Promise<Set<string>>;
11
20
  /** 已知工作区条目(dsh 的 workspace.json 表)。 */
12
21
  export interface WorkspaceEntry {
13
22
  path: string;
@@ -40,6 +40,11 @@ export interface ImportJob {
40
40
  name: string;
41
41
  error: string;
42
42
  }>;
43
+ /**
44
+ * 导入时被移入回收站让位的「已禁用同名占位」名字(issue #16)。占位已挪走
45
+ * 即计入,即使随后的下载失败也在列 —— 否则用户不知道自己的占位不见了。
46
+ */
47
+ replaced: string[];
43
48
  status: 'running' | 'done' | 'cancelled' | 'error';
44
49
  error?: string;
45
50
  controller: AbortController;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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": {
@@ -65,7 +65,8 @@
65
65
  "0.1.7-alpha.2",
66
66
  "0.1.7-rc.1",
67
67
  "0.1.7-rc.2",
68
- "0.2.0-rc.1"
68
+ "0.2.0-rc.1",
69
+ "0.2.0-rc.2"
69
70
  ]
70
71
  },
71
72
  "capability": {
@@ -113,20 +114,20 @@
113
114
  },
114
115
  "devDependencies": {
115
116
  "@deepseek-ai/cordis": "^4.0.2",
116
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
117
- "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
118
- "@deepseek-ai/dsh-client-store": "^0.2.0-rc.1",
119
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.0-rc.1",
120
- "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.1",
121
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
122
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.0-rc.1",
123
- "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
124
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
125
- "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
126
- "@deepseek-ai/dsh-session-query": "^0.2.0-rc.1",
127
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
128
- "@deepseek-ai/dsh-skill": "^0.2.0-rc.1",
129
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
117
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
118
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
119
+ "@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
120
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.0-rc.2",
121
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.2",
122
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
123
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.0-rc.2",
124
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
125
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
126
+ "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
127
+ "@deepseek-ai/dsh-session-query": "^0.2.0-rc.2",
128
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
129
+ "@deepseek-ai/dsh-skill": "^0.2.0-rc.2",
130
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
130
131
  "@deepseek-ai/schemastery": "^3.18.3",
131
132
  "@types/js-yaml": "^4.0.9",
132
133
  "@types/node": "^22.20.0",
@@ -70,6 +70,7 @@ export const zhMarket = {
70
70
  'repo.files': '{count} 个文件 · {size}',
71
71
  'repo.imported': '导入成功 {count} 个',
72
72
  'repo.skippedExisting': '跳过已存在 {count} 个',
73
+ 'repo.replaced': '已替换禁用占位 {count} 个(旧目录已移入回收站)',
73
74
  'repo.failed': '失败 {count} 个',
74
75
  'repo.cancel': '取消',
75
76
  'repo.cancelled': '已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理',
@@ -151,6 +152,7 @@ export const enMarket: Record<keyof typeof zhMarket, string> = {
151
152
  'repo.files': '{count} files · {size}',
152
153
  'repo.imported': 'Imported {count}',
153
154
  'repo.skippedExisting': 'Skipped existing {count}',
155
+ 'repo.replaced': 'Replaced {count} disabled placeholder(s) — old dir moved to trash',
154
156
  'repo.failed': 'Failed {count}',
155
157
  'repo.cancel': 'Cancel',
156
158
  'repo.cancelled': 'Cancelled · {imported}/{total} kept, temp files cleaned',
@@ -55,7 +55,7 @@ export function RepoScanCard(props: { hub: SkillHubState }): JSX.Element | null
55
55
  // 导入后 catalog 已包含新技能,但 scan 快照的 existing 仍为 false,需用实时 catalog + 本次导入结果兜底,避免已导入仍可选
56
56
  const installedNames = new Set<string>([
57
57
  ...((hub.catalog?.skills ?? []).map((s) => s.name)),
58
- ...((hub.catalog?.disabled ?? []).map((d) => d.name)),
58
+ // 已禁用不算「已存在」:禁用只该影响启用态,不该永久占名堵住导入(issue #16)。
59
59
  ...((repoResult?.imported ?? []).map((r) => r.name)),
60
60
  ...((repoResult?.skipped ?? []).map((r) => r.name)),
61
61
  ])
@@ -174,6 +174,7 @@ export function RepoScanCard(props: { hub: SkillHubState }): JSX.Element | null
174
174
  {repoResult !== null && repoResult.status !== 'running' ? (
175
175
  <div className={repoResult.status === 'cancelled' ? css.errorBanner + ' ' + css.actionsTop : css.formSuccess + ' ' + css.actionsTop}>
176
176
  {repoResult.status === 'cancelled' ? tt('repo.cancelled', { imported: repoResult.imported.length, total: repoResult.total }) : `${tt('repo.imported', { count: repoResult.imported.length })} · ${tt('repo.skippedExisting', { count: repoResult.skipped.length })} · ${tt('repo.failed', { count: repoResult.failed.length })}`}
177
+ {(repoResult.replaced?.length ?? 0) > 0 ? <span> · {tt('repo.replaced', { count: repoResult.replaced?.length ?? 0 })}</span> : null}
177
178
  {repoResult.failed.length > 0 ? <div style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
178
179
  {repoResult.status === 'error' && repoResult.error ? <div style={{marginTop:6}}>{repoResult.error}</div> : null}
179
180
  </div>
@@ -4,9 +4,17 @@
4
4
  on :root, with dark overrides on body[data-ds-dark-theme]). Every var()
5
5
  keeps the colour this file used before the token pass as its fallback, so a
6
6
  host without the theme package renders exactly as before. Sizes, spacing,
7
- radii, fonts and layout are deliberately untouched. */
7
+ radii, fonts and layout are deliberately untouched.
8
8
 
9
- .panel { padding: 28px 20px 44px; display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; color-scheme: light dark; /* Dot colors mirror src/client/SkillHubSettingsCard.tsx (DEFAULT_DOT_MODEL_COLOR / DEFAULT_DOT_USER_COLOR) — CSS cannot import TS, so keep the two in sync. */ --hub-model: #2f81f7; --hub-user: #3fb950; /* Host theme tokens (--dsw-alias-*, dsh-client-ui-theme). Each var() carries the pre-token colour of this file as its fallback, so a missing theme package changes nothing. */ --hub-accent: var(--dsw-alias-state-business-primary, #2f81f7); --hub-text: var(--dsw-alias-label-primary, currentColor); --hub-text-2: var(--dsw-alias-label-secondary, #61666b); --hub-text-3: var(--dsw-alias-label-tertiary, #81858c); --hub-text-4: var(--dsw-alias-label-caption, #adb2b8); --hub-surface: var(--dsw-alias-bg-module-platform, rgba(128,128,128,.06)); --hub-hover: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.08)); --hub-active: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.16)); --hub-border: var(--dsw-alias-border-l2, rgba(128,128,128,.13)); --hub-border-2: var(--dsw-alias-border-l3, rgba(128,128,128,.2)); --hub-danger: var(--dsw-alias-state-error-primary, #d1242f); --hub-success: var(--dsw-alias-state-success-primary, #3fb950); --hub-warn: var(--dsw-alias-state-warn-primary, #d29922); --hub-on-accent: var(--dsw-alias-label-primary-foreground, #fff); --hub-skeleton: var(--dsw-alias-bg-skeleton, rgba(128,128,128,.1)); }
9
+ color-scheme is deliberately NOT declared here. The host layout writes the
10
+ *resolved* scheme to document.documentElement.style.colorScheme; declaring
11
+ `light dark` locally overrides that and makes native controls (<select>
12
+ popups, scrollbars) follow the *OS* preference instead — with a manually
13
+ selected dark theme on a light OS the <option> list renders light while
14
+ .select inherits light text, i.e. invisible (issue #13). Inheriting from the
15
+ root keeps both dark paths (manual + system) correct. */
16
+
17
+ .panel { padding: 28px 20px 44px; display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; /* Dot colors mirror src/client/SkillHubSettingsCard.tsx (DEFAULT_DOT_MODEL_COLOR / DEFAULT_DOT_USER_COLOR) — CSS cannot import TS, so keep the two in sync. */ --hub-model: #2f81f7; --hub-user: #3fb950; /* Host theme tokens (--dsw-alias-*, dsh-client-ui-theme). Each var() carries the pre-token colour of this file as its fallback, so a missing theme package changes nothing. */ --hub-accent: var(--dsw-alias-state-business-primary, #2f81f7); --hub-text: var(--dsw-alias-label-primary, currentColor); --hub-text-2: var(--dsw-alias-label-secondary, #61666b); --hub-text-3: var(--dsw-alias-label-tertiary, #81858c); --hub-text-4: var(--dsw-alias-label-caption, #adb2b8); --hub-surface: var(--dsw-alias-bg-module-platform, rgba(128,128,128,.06)); --hub-hover: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.08)); --hub-active: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.16)); --hub-border: var(--dsw-alias-border-l2, rgba(128,128,128,.13)); --hub-border-2: var(--dsw-alias-border-l3, rgba(128,128,128,.2)); --hub-danger: var(--dsw-alias-state-error-primary, #d1242f); --hub-success: var(--dsw-alias-state-success-primary, #3fb950); --hub-warn: var(--dsw-alias-state-warn-primary, #d29922); --hub-on-accent: var(--dsw-alias-label-primary-foreground, #fff); --hub-skeleton: var(--dsw-alias-bg-skeleton, rgba(128,128,128,.1)); }
10
18
  .header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
11
19
  .title { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
12
20
  .hint { font-size: 12px; color: var(--hub-text-3); }
@@ -6,14 +6,6 @@
6
6
  */
7
7
  export const SKILL_HUB_API_ROOT = '/api/skill-hub'
8
8
 
9
- /**
10
- * Deprecated path of the market update check, kept routable for one release
11
- * after the naming unification below. A browser tab that loaded the previous
12
- * client bundle keeps calling it until it reloads; delete this (and its route
13
- * in routes/market.ts) in the next minor.
14
- */
15
- export const SKILL_HUB_API_DEPRECATED_MARKET_CHECK = '/api/skill-hub/market/check'
16
-
17
9
  /**
18
10
  * Browser-facing base paths of the skill-hub API family.
19
11
  *
@@ -95,6 +95,12 @@ export interface RepoImportProgressResponse {
95
95
  imported: Array<{ name: string; origin: string; path: string }>
96
96
  skipped: Array<{ name: string; reason: 'exists' }>
97
97
  failed: Array<{ name: string; error: string }>
98
+ /**
99
+ * 本次导入中「让位的已禁用同名占位」名字(issue #16):这些条目的旧目录已
100
+ * 移入回收站,新技能随后落地。旧目录挪走即计入,下载失败时也在列。老客户端
101
+ * 不认这个字段时忽略即可,故为可选。
102
+ */
103
+ replaced?: string[]
98
104
  /** error 状态时的原因 */
99
105
  error?: string
100
106
  }
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * 新代码可按需直引 `from './protocol/<domain>.ts'`。
9
9
  */
10
10
 
11
- export { SKILL_HUB_API, SKILL_HUB_API_ROOT, SKILL_HUB_API_DEPRECATED_MARKET_CHECK } from './protocol/api.ts'
11
+ export { SKILL_HUB_API, SKILL_HUB_API_ROOT } from './protocol/api.ts'
12
12
  export type {
13
13
  WritableRoot,
14
14
  HubInvocation,
@@ -34,8 +34,11 @@ export async function downloadGitHubFile(repo: string, ref: string, path: string
34
34
  }
35
35
  if (response === null || !response.ok) {
36
36
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
37
- // Fallback: api.github.com/contents with the raw media type.
38
- const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`
37
+ // Fallback: api.github.com/contents with the raw media type. The `?ref=`
38
+ // is mandatory — without it /contents/ serves the default branch, so a
39
+ // transient raw failure would silently mix default-branch content into a
40
+ // tag-pinned import (issue #15).
41
+ const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`
39
42
  try {
40
43
  response = await fetchImpl(apiUrl, { headers: { accept: 'application/vnd.github.raw', ...NO_COMPRESSION, ...githubAuthHeaders() }, ...(signal !== undefined ? { signal } : {}) })
41
44
  } catch (error) {
package/src/repo.test.ts CHANGED
@@ -366,6 +366,26 @@ describe('downloadGitHubFile', () => {
366
366
  expect(buffer.toString('utf8')).toContain('name: demo')
367
367
  expect(calls.length).toBe(2)
368
368
  expect(calls[1]).toContain('api.github.com/repos/example/repo/contents/skills/demo/SKILL.md')
369
+ expect(calls[1]).toContain('ref=main')
370
+ })
371
+
372
+ // issue #15: the fallback dropped the ref, so /contents/ served the default
373
+ // branch and a tag/branch-pinned import silently mixed main-branch content.
374
+ it('keeps the requested ref on the api fallback (never the default branch)', async () => {
375
+ const calls: string[] = []
376
+ const ref = 'andibrae/create-top-level-namespace'
377
+ const fetchImpl = async (url: string) => {
378
+ calls.push(url)
379
+ if (url.startsWith('https://raw.githubusercontent.com/')) throw new Error('raw blocked')
380
+ if (url.includes('/contents/')) return new Response('---\nname: demo\ndescription: x\n---\n\nref body', { status: 200 })
381
+ return new Response('nope', { status: 599 })
382
+ }
383
+ await downloadGitHubFile('example/repo', ref, 'skills/demo/SKILL.md', fetchImpl as typeof fetch)
384
+ expect(calls[1]).toBe(
385
+ 'https://api.github.com/repos/example/repo/contents/skills/demo/SKILL.md?ref=' + encodeURIComponent(ref),
386
+ )
387
+ // The un-ref'd form is exactly what served default-branch content.
388
+ expect(calls[1]).not.toBe('https://api.github.com/repos/example/repo/contents/skills/demo/SKILL.md')
369
389
  })
370
390
 
371
391
  it('reports the original error when both hosts fail', async () => {
@@ -17,10 +17,21 @@ import { rootPath, readSkillInterface, scanDiagnostics, type SkillInterface } fr
17
17
  import { CURRENT_VERSION } from '../update.ts'
18
18
  import { homeOf, isWritableSource, type SkillHubRouteDeps } from './deps.ts'
19
19
 
20
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
21
- export async function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>> {
20
+ /**
21
+ * 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
22
+ * 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
23
+ *
24
+ * 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
25
+ * 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
26
+ * (issue #16)。
27
+ */
28
+ export async function knownSkillNames(
29
+ deps: SkillHubRouteDeps,
30
+ options?: { includeDisabled?: boolean },
31
+ ): Promise<Set<string>> {
22
32
  const snapshot = await deps.skills.snapshot()
23
33
  const names = new Set(snapshot.skills.map((skill) => skill.name))
34
+ if (options?.includeDisabled === false) return names
24
35
  for (const disabled of await deps.store.listDisabled()) names.add(disabled.name)
25
36
  return names
26
37
  }
@@ -6,7 +6,6 @@
6
6
 
7
7
  import {
8
8
  SKILL_HUB_API,
9
- SKILL_HUB_API_DEPRECATED_MARKET_CHECK,
10
9
  type MarketCheckResponse,
11
10
  type MarketSourceResponse,
12
11
  type MarketSourcesResponse,
@@ -46,8 +45,7 @@ import {
46
45
  export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
47
46
  // Update check over every market source: compares the pinned ref's commit
48
47
  // against the recorded baseline and surfaces newer releases. 5-minute
49
- // throttle mirrors the source check. Shared by the canonical route and its
50
- // deprecated alias, so the two can never drift apart.
48
+ // throttle mirrors the source check.
51
49
  const checkMarketSources: RouteHandler = async ({ res }) => {
52
50
  const sources = await deps.store.listMarketSources()
53
51
  if (lastMarketCheck.size > 500) lastMarketCheck.clear()
@@ -161,13 +159,6 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
161
159
  methods: ['GET'],
162
160
  handler: checkMarketSources,
163
161
  },
164
- // Deprecated alias, kept for one release: a browser tab still running the
165
- // previous client bundle calls the old path until it reloads.
166
- {
167
- path: SKILL_HUB_API_DEPRECATED_MARKET_CHECK,
168
- methods: ['GET'],
169
- handler: checkMarketSources,
170
- },
171
162
  // ------------------------------------------------------- market/stats
172
163
  // Stars + release-asset downloads per market source. Stale-while-revalidate:
173
164
  // the base call always answers from cache instantly (stale-flagged past the
@@ -3,8 +3,8 @@
3
3
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
4
4
  */
5
5
 
6
- import { mkdir } from 'node:fs/promises'
7
- import { join } from 'node:path'
6
+ import { mkdir, rename } from 'node:fs/promises'
7
+ import { dirname, join } from 'node:path'
8
8
  import { randomUUID } from 'node:crypto'
9
9
  import {
10
10
  SKILL_HUB_API,
@@ -44,6 +44,32 @@ import {
44
44
  } from './helpers.ts'
45
45
  import { gcImportJobs, importJobs, type ImportJob } from './route-state.ts'
46
46
 
47
+ /**
48
+ * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
49
+ * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
50
+ * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
51
+ *
52
+ * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
53
+ * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
54
+ */
55
+ async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
56
+ const record = await deps.store.getDisabled(name)
57
+ if (record === undefined) return false
58
+ const dir = join(targetRoot, name)
59
+ const sourceToTrash = record.path.endsWith('SKILL.md.disabled') ? dirname(record.path) : record.path
60
+ if (sourceToTrash !== dir) return false
61
+ // 目录里还有 SKILL.md 说明它其实是启用态的真实技能,不动。
62
+ if (await pathExists(join(dir, 'SKILL.md'))) return false
63
+ // 与 skill/delete 的回收站语义一致:同级 .trash/ 下按「名字-时间戳」落盘,可恢复。
64
+ const trashDir = join(targetRoot, '.trash')
65
+ await mkdir(trashDir, { recursive: true })
66
+ const target = join(trashDir, name + '-' + Date.now())
67
+ await rename(sourceToTrash, target)
68
+ await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
69
+ await deps.store.removeDisabled(name)
70
+ return true
71
+ }
72
+
47
73
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
48
74
  export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
49
75
  return [
@@ -86,7 +112,8 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
86
112
  }
87
113
  }
88
114
  const { ref: resolvedRef, tree, truncated } = await loadRepoTree(repo, ref)
89
- const existing = await knownSkillNames(deps)
115
+ // 发现态也只看「已启用」:否则已禁用占位会把真技能标成「已存在」并禁掉勾选(issue #16)。
116
+ const existing = await knownSkillNames(deps, { includeDisabled: false })
90
117
  const entries = discoverRepoEntries(tree, repo, existing)
91
118
  writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries, ...(truncated ? { truncated: true } : {}) } satisfies RepoDiscoverResponse)
92
119
  },
@@ -108,7 +135,8 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
108
135
  const requestedRef = readString(body, 'ref')
109
136
  const ref = requestedRef !== '' ? requestedRef : parsed.ref
110
137
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
111
- const existing = await knownSkillNames(deps)
138
+ // 导入去重同 discover:已禁用不算「已存在」(issue #16)。
139
+ const existing = await knownSkillNames(deps, { includeDisabled: false })
112
140
  const entries = discoverRepoEntries(tree, repo, existing)
113
141
  // truncated tree is still usable for the selected paths the user already picked from the prior discover response
114
142
  const byPath = new Map(entries.map((entry) => [entry.path, entry]))
@@ -139,6 +167,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
139
167
  imported: [],
140
168
  skipped: [],
141
169
  failed: [],
170
+ replaced: [],
142
171
  status: 'running',
143
172
  controller,
144
173
  createdAt: now,
@@ -166,11 +195,25 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
166
195
  job.done += 1
167
196
  continue
168
197
  }
198
+ // 磁盘上有同名目录。若它只是一个已禁用的占位(目录里只剩
199
+ // SKILL.md.disabled),先把旧目录移入回收站再继续导入;移不动或
200
+ // 不是这种形态时退回原本的「跳过」(issue #16)。
169
201
  if (await pathExists(join(targetRoot, entry.name))) {
170
- job.skipped.push({ name: entry.name, reason: 'exists' })
171
- job.downloadedBytes += entry.totalBytes
172
- job.done += 1
173
- continue
202
+ let cleared = false
203
+ try {
204
+ cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
205
+ } catch {
206
+ cleared = false
207
+ }
208
+ if (!cleared) {
209
+ job.skipped.push({ name: entry.name, reason: 'exists' })
210
+ job.downloadedBytes += entry.totalBytes
211
+ job.done += 1
212
+ continue
213
+ }
214
+ // 旧目录已经进了回收站(可恢复)。即使随后的下载失败也必须如实
215
+ // 上报,否则用户不知道自己的占位被挪走了。
216
+ job.replaced.push(entry.name)
174
217
  }
175
218
  if (controller.signal.aborted) break
176
219
  const files = collectRepoSkillFiles(tree, entry.dir)
@@ -241,6 +284,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
241
284
  imported: [...job.imported],
242
285
  skipped: [...job.skipped],
243
286
  failed: [...job.failed],
287
+ ...(job.replaced.length > 0 ? { replaced: [...job.replaced] } : {}),
244
288
  ...(job.error !== undefined ? { error: job.error } : {}),
245
289
  } satisfies RepoImportProgressResponse)
246
290
  },
@@ -16,6 +16,7 @@ function job(jobId: string, createdAt: number, status: ImportJob['status'] = 'do
16
16
  imported: [],
17
17
  skipped: [],
18
18
  failed: [],
19
+ replaced: [],
19
20
  status,
20
21
  controller: new AbortController(),
21
22
  createdAt,
@@ -51,6 +51,11 @@ export interface ImportJob {
51
51
  imported: Array<{ name: string; origin: string; path: string }>
52
52
  skipped: Array<{ name: string; reason: 'exists' }>
53
53
  failed: Array<{ name: string; error: string }>
54
+ /**
55
+ * 导入时被移入回收站让位的「已禁用同名占位」名字(issue #16)。占位已挪走
56
+ * 即计入,即使随后的下载失败也在列 —— 否则用户不知道自己的占位不见了。
57
+ */
58
+ replaced: string[]
54
59
  status: 'running' | 'done' | 'cancelled' | 'error'
55
60
  error?: string
56
61
  controller: AbortController