dsh-skill-hub 0.3.18 → 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
@@ -2133,7 +2133,7 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
2133
2133
  }
2134
2134
  if (response === null || !response.ok) {
2135
2135
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
2136
- const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`;
2136
+ const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`;
2137
2137
  try {
2138
2138
  response = await fetchImpl(apiUrl, {
2139
2139
  headers: {
@@ -2539,10 +2539,18 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2539
2539
  * routes 共享层 · 目录装配:已知工作区清单、完整目录响应(时间戳/interface
2540
2540
  * 元数据/重名诊断)与详情映射。从 helpers.ts 原样搬出,行为不变。
2541
2541
  */
2542
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
2543
- 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) {
2544
2551
  const snapshot = await deps.skills.snapshot();
2545
2552
  const names = new Set(snapshot.skills.map((skill) => skill.name));
2553
+ if (options?.includeDisabled === false) return names;
2546
2554
  for (const disabled of await deps.store.listDisabled()) names.add(disabled.name);
2547
2555
  return names;
2548
2556
  }
@@ -3531,6 +3539,34 @@ function marketRoutes(deps) {
3531
3539
  * 仓库导入域路由:repo 发现 / repoImport 异步导入任务 / progress 轮询 /
3532
3540
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3533
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
+ }
3534
3570
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3535
3571
  function repoImportRoutes(deps) {
3536
3572
  return [
@@ -3576,7 +3612,7 @@ function repoImportRoutes(deps) {
3576
3612
  ok: true,
3577
3613
  repo,
3578
3614
  ref: resolvedRef,
3579
- entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps)),
3615
+ entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps, { includeDisabled: false })),
3580
3616
  ...truncated ? { truncated: true } : {}
3581
3617
  });
3582
3618
  }
@@ -3600,7 +3636,7 @@ function repoImportRoutes(deps) {
3600
3636
  const repo = repoSlug(parsed);
3601
3637
  const requestedRef = readString(body, "ref");
3602
3638
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, requestedRef !== "" ? requestedRef : parsed.ref);
3603
- const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps));
3639
+ const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps, { includeDisabled: false }));
3604
3640
  const byPath = new Map(entries.map((entry) => [entry.path, entry]));
3605
3641
  const selected = paths.map((path) => byPath.get(path)).filter((entry) => entry !== void 0);
3606
3642
  if (selected.length === 0) {
@@ -3633,6 +3669,7 @@ function repoImportRoutes(deps) {
3633
3669
  imported: [],
3634
3670
  skipped: [],
3635
3671
  failed: [],
3672
+ replaced: [],
3636
3673
  status: "running",
3637
3674
  controller,
3638
3675
  createdAt: now
@@ -3659,13 +3696,22 @@ function repoImportRoutes(deps) {
3659
3696
  continue;
3660
3697
  }
3661
3698
  if (await pathExists(join(targetRoot, entry.name))) {
3662
- job.skipped.push({
3663
- name: entry.name,
3664
- reason: "exists"
3665
- });
3666
- job.downloadedBytes += entry.totalBytes;
3667
- job.done += 1;
3668
- 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);
3669
3715
  }
3670
3716
  if (controller.signal.aborted) break;
3671
3717
  const files = collectRepoSkillFiles(tree, entry.dir);
@@ -3748,6 +3794,7 @@ function repoImportRoutes(deps) {
3748
3794
  imported: [...job.imported],
3749
3795
  skipped: [...job.skipped],
3750
3796
  failed: [...job.failed],
3797
+ ...job.replaced.length > 0 ? { replaced: [...job.replaced] } : {},
3751
3798
  ...job.error !== void 0 ? { error: job.error } : {}
3752
3799
  });
3753
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} 已保留,临时文件已清理";
@@ -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
  }
@@ -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.18",
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); }
@@ -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
  }
@@ -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
  }
@@ -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