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/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +4 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +59 -12
- package/lib/types/client/locales/market.d.ts +1 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/protocol/repo.d.ts +6 -0
- package/lib/types/routes/catalog-data.d.ts +11 -2
- package/lib/types/routes/route-state.d.ts +5 -0
- package/package.json +17 -16
- package/src/client/locales/market.ts +2 -0
- package/src/client/panel/RepoScanCard.tsx +2 -1
- package/src/client/panel/panel.module.css +10 -2
- package/src/protocol/repo.ts +6 -0
- package/src/repo/install.ts +5 -2
- package/src/repo.test.ts +20 -0
- package/src/routes/catalog-data.ts +13 -2
- package/src/routes/repo-import.ts +52 -8
- package/src/routes/route-state.test.ts +1 -0
- package/src/routes/route-state.ts +5 -0
- package/src/routes.test.ts +119 -0
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
|
-
/**
|
|
2543
|
-
|
|
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
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
}
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
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.
|
|
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.
|
|
117
|
-
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.
|
|
118
|
-
"@deepseek-ai/dsh-client-store": "^0.2.0-rc.
|
|
119
|
-
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.2.0-rc.
|
|
120
|
-
"@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.
|
|
121
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.
|
|
122
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.2.0-rc.
|
|
123
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.
|
|
124
|
-
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.
|
|
125
|
-
"@deepseek-ai/dsh-session": "^0.2.0-rc.
|
|
126
|
-
"@deepseek-ai/dsh-session-query": "^0.2.0-rc.
|
|
127
|
-
"@deepseek-ai/dsh-settings": "^0.2.0-rc.
|
|
128
|
-
"@deepseek-ai/dsh-skill": "^0.2.0-rc.
|
|
129
|
-
"@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.
|
|
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
|
-
|
|
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
|
-
|
|
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); }
|
package/src/protocol/repo.ts
CHANGED
|
@@ -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/repo/install.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
},
|
|
@@ -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
|