dsh-skill-hub 0.3.20 → 0.3.21
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 +4 -3
- package/README.zh.md +4 -3
- package/lib/client.js.map +1 -1
- package/lib/index.js +303 -69
- package/lib/types/protocol/sources.d.ts +5 -0
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/routes/route-state.d.ts +22 -2
- package/lib/types/skillfs/scan.d.ts +26 -0
- package/lib/types/skillfs.d.ts +9 -1
- package/lib/types/store/migrate.d.ts +1 -1
- package/lib/types/store/store.d.ts +10 -5
- package/package.json +1 -1
- package/src/index.ts +11 -1
- package/src/protocol/sources.ts +5 -0
- package/src/reconcile.test.ts +74 -1
- package/src/reconcile.ts +50 -1
- package/src/routes/catalog.ts +50 -22
- package/src/routes/repo-import.ts +16 -26
- package/src/routes/route-state.test.ts +4 -4
- package/src/routes/route-state.ts +45 -10
- package/src/routes/sources.ts +79 -23
- package/src/routes.test.ts +232 -0
- package/src/skillfs/scan.ts +91 -2
- package/src/skillfs.ts +14 -2
- package/src/store/migrate.ts +6 -4
- package/src/store/store.ts +21 -11
- package/src/store.test.ts +17 -1
|
@@ -46,3 +46,29 @@ export declare function findProjectRoot(cwd: string): Promise<string>;
|
|
|
46
46
|
* a defect.
|
|
47
47
|
*/
|
|
48
48
|
export declare function scanDiagnostics(root: WritableRoot, home?: string): Promise<DiagnosticEntry[]>;
|
|
49
|
+
/**
|
|
50
|
+
* 某个名字在一个 root 下现存的禁用发现文件(两种形态都认),没有则 undefined。
|
|
51
|
+
* 目录包形态优先(`<name>/SKILL.md.disabled`),其次扁平 `<name>.md.disabled`。
|
|
52
|
+
* 单点定义「禁用态在磁盘上长什么样」,避免各处再拼一次路径(issue #16 类)。
|
|
53
|
+
*/
|
|
54
|
+
export declare function disabledOccupancyIn(base: string, name: string): Promise<string | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* 一个名字在某个 root 下的全部磁盘占位。技能名是系统里唯一的身份,但同一个名字
|
|
57
|
+
* 可以被两种形态(目录包 `<name>/SKILL.md` / 扁平 `<name>.md`)× 两种状态
|
|
58
|
+
* (启用 / `.disabled` 改名)占据,而且两种形态可以**同时存在** —— 注册表只会
|
|
59
|
+
* 给出其中一个,另一个静默不可达。所有按名字操作文件的路径都应该从这里取值,
|
|
60
|
+
* 而不是各自拼一次路径(issue #16 类的根因)。
|
|
61
|
+
*
|
|
62
|
+
* 两个数组都按「目录包优先」排序:目录包是 hub 自己写入的形态(导入、创建、
|
|
63
|
+
* 同步都建目录包),冲突时它是更「权威」的那份。
|
|
64
|
+
*/
|
|
65
|
+
export interface NameOccupancy {
|
|
66
|
+
/** 启用态发现文件(目录包 `<name>/SKILL.md` 在前,扁平 `<name>.md` 在后)。 */
|
|
67
|
+
enabled: string[];
|
|
68
|
+
/** 禁用态发现文件(`<name>/SKILL.md.disabled` 在前,`<name>.md.disabled` 在后)。 */
|
|
69
|
+
disabled: string[];
|
|
70
|
+
/** `<name>/` 存在,但既没有 `SKILL.md` 也没有 `SKILL.md.disabled`(空壳/残留目录)。 */
|
|
71
|
+
bareDir?: string;
|
|
72
|
+
}
|
|
73
|
+
/** 读一个名字在某个 skills root 下的全部占位。只读,不修改磁盘。 */
|
|
74
|
+
export declare function occupancyOf(base: string, name: string): Promise<NameOccupancy>;
|
package/lib/types/skillfs.d.ts
CHANGED
|
@@ -45,11 +45,19 @@ export declare function moveIntoTrash(source: string): Promise<{
|
|
|
45
45
|
path: string;
|
|
46
46
|
source: string;
|
|
47
47
|
}>;
|
|
48
|
+
/**
|
|
49
|
+
* The filesystem entry behind a discovery file: a directory bundle is addressed
|
|
50
|
+
* by its `SKILL.md` (or `SKILL.md.disabled`) and moved as a whole directory, a
|
|
51
|
+
* flat `<name>.md` file is moved as itself. Single definition of that mapping —
|
|
52
|
+
* delete, restore, sync-replace and placeholder retirement must all agree on it,
|
|
53
|
+
* or one of them moves a bundle's discovery file and leaves an empty directory.
|
|
54
|
+
*/
|
|
55
|
+
export declare function trashSourceOf(discoveryPath: string): string;
|
|
48
56
|
/**
|
|
49
57
|
* Move a skill (directory bundle or flat .md file) into the root's trash.
|
|
50
58
|
* The skill stays recoverable: the source is renamed into
|
|
51
59
|
* <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
|
|
52
|
-
* SKILL.md path and moved as a whole directory.
|
|
60
|
+
* SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
|
|
53
61
|
* @returns the trashed path plus the original path that was moved.
|
|
54
62
|
*/
|
|
55
63
|
export declare function trashSkill(sourcePath: string): Promise<{
|
|
@@ -31,7 +31,7 @@ export interface HydratedState {
|
|
|
31
31
|
tagsById: Map<string, SkillTag>;
|
|
32
32
|
sourcesByRepo: Map<string, SourceRecord>;
|
|
33
33
|
marketSources: MarketSourceRecord[];
|
|
34
|
-
|
|
34
|
+
trashByPath: Map<string, TrashEntry>;
|
|
35
35
|
skillStats: SkillStatsCheckpoint | undefined;
|
|
36
36
|
marketStats: MarketStatsSnapshot | undefined;
|
|
37
37
|
collectionOrder: string[];
|
|
@@ -7,7 +7,7 @@ export declare class SkillHubStore {
|
|
|
7
7
|
private tagsById;
|
|
8
8
|
private sourcesByRepo;
|
|
9
9
|
private marketSources;
|
|
10
|
-
private
|
|
10
|
+
private trashByPath;
|
|
11
11
|
private skillStats;
|
|
12
12
|
private marketStats;
|
|
13
13
|
private collectionOrder;
|
|
@@ -119,12 +119,17 @@ export declare class SkillHubStore {
|
|
|
119
119
|
setMarketSourceCommit(repo: string, commitSha: string): Promise<void>;
|
|
120
120
|
/** All trashed skills, newest first. */
|
|
121
121
|
listTrash(): Promise<TrashEntry[]>;
|
|
122
|
-
/** One trash entry by skill name
|
|
122
|
+
/** One trash entry by skill name — the newest when a name has several. */
|
|
123
123
|
getTrash(name: string): Promise<TrashEntry | undefined>;
|
|
124
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Record a trashed skill, keyed by its trashed path. Keying by name used to
|
|
126
|
+
* silently drop the previous record when the same name landed in the trash
|
|
127
|
+
* twice (a re-delete, or the two on-disk discovery forms), orphaning its
|
|
128
|
+
* directory with nothing left pointing at it.
|
|
129
|
+
*/
|
|
125
130
|
addTrash(entry: TrashEntry): Promise<void>;
|
|
126
|
-
/** Remove a trash record (after restore). */
|
|
127
|
-
removeTrash(
|
|
131
|
+
/** Remove a trash record by its trashed path (after restore or permanent clear). */
|
|
132
|
+
removeTrash(path: string): Promise<void>;
|
|
128
133
|
/** The persisted usage-statistics checkpoint (undefined until first saved). */
|
|
129
134
|
getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined>;
|
|
130
135
|
/** Persist a usage-statistics checkpoint (after every completed scan; cadence follows the scan TTL). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-hub",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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": {
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { SkillHubProvider } from './provider.ts'
|
|
|
24
24
|
import { makeRoutes } from './routes.ts'
|
|
25
25
|
import { createSkillStatsReader, asPersistenceSeam, type SessionPersistenceLike, type SessionQueryLike, type SkillStatsReader } from './stats.ts'
|
|
26
26
|
import { SkillHubStore } from './store.ts'
|
|
27
|
-
import { reconcileDisabledSkills } from './reconcile.ts'
|
|
27
|
+
import { reconcileDisabledSkills, pruneStaleDisabledSkills } from './reconcile.ts'
|
|
28
28
|
import { cleanupLeftoverImportDirs, setGithubToken } from './repo.ts'
|
|
29
29
|
import { dshHome } from './store.ts'
|
|
30
30
|
import { join } from 'node:path'
|
|
@@ -307,6 +307,16 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
307
307
|
} catch (error) {
|
|
308
308
|
ctx.logger.warn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
|
|
309
309
|
}
|
|
310
|
+
// 反向对账:记录还在、文件已不在 → 修路径或删记录。不做这一步,那条记录会
|
|
311
|
+
// 把名字永久锁死(启用/新建/删除三条路都失败)。
|
|
312
|
+
try {
|
|
313
|
+
const pruned = await pruneStaleDisabledSkills(store, home)
|
|
314
|
+
if (pruned.repaired.length > 0 || pruned.removed.length > 0) {
|
|
315
|
+
ctx.logger.info(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(', ') || '-'}), removed ${pruned.removed.length} (${pruned.removed.join(', ') || '-'})`)
|
|
316
|
+
}
|
|
317
|
+
} catch (error) {
|
|
318
|
+
ctx.logger.warn('[dsh-skill-hub] startup disabled-record prune failed', error)
|
|
319
|
+
}
|
|
310
320
|
})()
|
|
311
321
|
|
|
312
322
|
void (async () => {
|
package/src/protocol/sources.ts
CHANGED
|
@@ -138,6 +138,11 @@ export interface SourceTrashClearResponse {
|
|
|
138
138
|
deleted: string[]
|
|
139
139
|
/** 未能删除的技能(保留在回收站中)。 */
|
|
140
140
|
failed: Array<{ name: string; error: string }>
|
|
141
|
+
/**
|
|
142
|
+
* `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
|
|
143
|
+
* 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
|
|
144
|
+
*/
|
|
145
|
+
orphans?: string[]
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
/** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
|
package/src/reconcile.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { reconcileDisabledSkills } from './reconcile.ts'
|
|
5
|
+
import { pruneStaleDisabledSkills, reconcileDisabledSkills } from './reconcile.ts'
|
|
6
6
|
import { SkillHubStore, statePath } from './store.ts'
|
|
7
7
|
|
|
8
8
|
describe('reconcileDisabledSkills', () => {
|
|
@@ -73,4 +73,77 @@ describe('reconcileDisabledSkills', () => {
|
|
|
73
73
|
expect(await reconcileDisabledSkills(store, home)).toEqual([])
|
|
74
74
|
expect(await store.listDisabled()).toHaveLength(1)
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
it('reconciles a root-level SKILL.md.disabled (a flat skill literally named SKILL)', async () => {
|
|
78
|
+
// scanRoot 会把根级 SKILL.md 文件当一个扁平技能,它的禁用对应物就不能被漏掉。
|
|
79
|
+
await writeFile(join(home, 'skills', 'SKILL.md.disabled'), '---\nname: root-flat\ndescription: Root flat\n---', 'utf8')
|
|
80
|
+
const added = await reconcileDisabledSkills(store, home)
|
|
81
|
+
expect(added.map((entry) => entry.name)).toEqual(['root-flat'])
|
|
82
|
+
expect((await store.getDisabled('root-flat'))?.path).toBe(join(home, 'skills', 'SKILL.md.disabled'))
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('pruneStaleDisabledSkills', () => {
|
|
87
|
+
let dir: string
|
|
88
|
+
let home: string
|
|
89
|
+
let agentsHome: string
|
|
90
|
+
let previousAgentsHome: string | undefined
|
|
91
|
+
let store: SkillHubStore
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-prune-'))
|
|
95
|
+
home = join(dir, 'home')
|
|
96
|
+
agentsHome = join(dir, 'agents')
|
|
97
|
+
await mkdir(join(home, 'skills'), { recursive: true })
|
|
98
|
+
await mkdir(join(agentsHome, 'skills'), { recursive: true })
|
|
99
|
+
previousAgentsHome = process.env.DSH_AGENTS_HOME
|
|
100
|
+
process.env.DSH_AGENTS_HOME = agentsHome
|
|
101
|
+
store = new SkillHubStore(statePath(home))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
afterEach(async () => {
|
|
105
|
+
if (previousAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
|
|
106
|
+
else process.env.DSH_AGENTS_HOME = previousAgentsHome
|
|
107
|
+
await rm(dir, { recursive: true, force: true })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('drops a record whose file is gone and frees the name', async () => {
|
|
111
|
+
await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
112
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
113
|
+
expect(result).toEqual({ repaired: [], removed: ['ghost'] })
|
|
114
|
+
expect(await store.listDisabled()).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('repoints a record to the surviving flat placeholder instead of dropping it', async () => {
|
|
118
|
+
// 记录指目录形态,磁盘上只剩扁平形态(导入让位过一次的典型残留)。
|
|
119
|
+
await writeFile(join(home, 'skills', 'code-review.md.disabled'), '---\nname: code-review\ndescription: stub\n---', 'utf8')
|
|
120
|
+
await store.addDisabled({ name: 'code-review', description: 'stub', path: join(home, 'skills', 'code-review', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
121
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
122
|
+
expect(result.removed).toEqual([])
|
|
123
|
+
expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'code-review.md.disabled')])
|
|
124
|
+
expect((await store.getDisabled('code-review'))?.path).toBe(join(home, 'skills', 'code-review.md.disabled'))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('repoints to the bundle placeholder when that is the surviving form', async () => {
|
|
128
|
+
await mkdir(join(home, 'skills', 'paused'))
|
|
129
|
+
await writeFile(join(home, 'skills', 'paused', 'SKILL.md.disabled'), '---\nname: paused\ndescription: b\n---', 'utf8')
|
|
130
|
+
await store.addDisabled({ name: 'paused', description: 'b', path: join(home, 'skills', 'paused.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
131
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
132
|
+
expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'paused', 'SKILL.md.disabled')])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('leaves records whose file still exists alone, and is idempotent', async () => {
|
|
136
|
+
await writeFile(join(home, 'skills', 'alive.md.disabled'), '---\nname: alive\ndescription: x\n---', 'utf8')
|
|
137
|
+
await store.addDisabled({ name: 'alive', description: 'x', path: join(home, 'skills', 'alive.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
138
|
+
expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
|
|
139
|
+
expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
|
|
140
|
+
expect(await store.listDisabled()).toHaveLength(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('prunes records from the other writable root too', async () => {
|
|
144
|
+
await store.addDisabled({ name: 'agent-ghost', description: 'x', path: join(agentsHome, 'skills', 'agent-ghost', 'SKILL.md.disabled'), root: 'user-agents', disabledAt: 1 })
|
|
145
|
+
await writeFile(join(agentsHome, 'skills', 'agent-ghost.md.disabled'), '---\nname: agent-ghost\ndescription: x\n---', 'utf8')
|
|
146
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
147
|
+
expect(result.repaired.map((entry) => entry.path)).toEqual([join(agentsHome, 'skills', 'agent-ghost.md.disabled')])
|
|
148
|
+
})
|
|
76
149
|
})
|
package/src/reconcile.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFile, stat } from 'node:fs/promises'
|
|
14
14
|
import type { DisabledSkill } from './protocol.ts'
|
|
15
|
-
import { parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
|
|
15
|
+
import { disabledOccupancyIn, parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
|
|
16
16
|
import { dshHome } from './store.ts'
|
|
17
17
|
|
|
18
18
|
/** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
|
|
@@ -21,6 +21,11 @@ export interface DisabledReconcileStore {
|
|
|
21
21
|
addDisabled(entry: DisabledSkill): Promise<void>
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Narrow store view used by the reverse reconcile (adds record removal). */
|
|
25
|
+
export interface DisabledPruneStore extends DisabledReconcileStore {
|
|
26
|
+
removeDisabled(name: string): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
/**
|
|
25
30
|
* Add sidecar disabled records for every `.disabled` discovery file that has
|
|
26
31
|
* none yet. Existing records (matched by path or by name) win, so this never
|
|
@@ -60,3 +65,47 @@ export async function reconcileDisabledSkills(store: DisabledReconcileStore, hom
|
|
|
60
65
|
}
|
|
61
66
|
return added
|
|
62
67
|
}
|
|
68
|
+
|
|
69
|
+
/** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
|
|
70
|
+
export interface DisabledPruneResult {
|
|
71
|
+
/** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
|
|
72
|
+
repaired: DisabledSkill[]
|
|
73
|
+
/** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
|
|
74
|
+
removed: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
|
|
79
|
+
* 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
|
|
80
|
+
* 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
|
|
81
|
+
* 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
|
|
82
|
+
*
|
|
83
|
+
* 两种处理,都不动启用态技能:
|
|
84
|
+
* - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
|
|
85
|
+
* - 一个都没有 → 删记录,把名字还给用户。
|
|
86
|
+
*
|
|
87
|
+
* 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
|
|
88
|
+
*/
|
|
89
|
+
export async function pruneStaleDisabledSkills(store: DisabledPruneStore, home = dshHome()): Promise<DisabledPruneResult> {
|
|
90
|
+
const result: DisabledPruneResult = { repaired: [], removed: [] }
|
|
91
|
+
for (const record of await store.listDisabled()) {
|
|
92
|
+
let exists = false
|
|
93
|
+
try {
|
|
94
|
+
exists = (await stat(record.path)).isFile()
|
|
95
|
+
} catch {
|
|
96
|
+
exists = false
|
|
97
|
+
}
|
|
98
|
+
if (exists) continue
|
|
99
|
+
if (!WRITABLE_ROOTS.includes(record.root)) continue
|
|
100
|
+
const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name)
|
|
101
|
+
if (occupancy === undefined) {
|
|
102
|
+
await store.removeDisabled(record.name)
|
|
103
|
+
result.removed.push(record.name)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const repaired: DisabledSkill = { ...record, path: occupancy }
|
|
107
|
+
await store.addDisabled(repaired)
|
|
108
|
+
result.repaired.push(repaired)
|
|
109
|
+
}
|
|
110
|
+
return result
|
|
111
|
+
}
|
package/src/routes/catalog.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
-
import { dirname
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
8
|
import type { SkillDefinition } from '@deepseek-ai/dsh-skill'
|
|
9
9
|
import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
10
10
|
import {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type ToggleResponse,
|
|
19
19
|
type WritableRoot,
|
|
20
20
|
} from '../protocol.ts'
|
|
21
|
-
import { createSkill, disableSkill, enableSkill,
|
|
21
|
+
import { createSkill, disableSkill, disabledOccupancyIn, enableSkill, occupancyOf, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
22
22
|
import { errorText } from '../error-text.ts'
|
|
23
23
|
import {
|
|
24
24
|
applyInterface,
|
|
@@ -146,18 +146,27 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
146
146
|
// 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
|
|
147
147
|
let trashResult: { path: string; source: string } | null = null
|
|
148
148
|
if (!resolved.ok) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
149
|
+
const disabled = await deps.store.getDisabled(name)
|
|
150
|
+
if (disabled !== undefined) {
|
|
151
|
+
// sidecar 里的路径可能是外部写入/损坏的:移动前必须确认它落在
|
|
152
|
+
// 可写根内(正常路径由 disableSkill 保证,这里只拦异常数据)。
|
|
153
|
+
if (rootOfPath(disabled.path, homeOf(deps)) === undefined) {
|
|
154
|
+
writeError(res, 409, 'disabled skill path is outside the hub writable roots')
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// 记录还在、文件已经不在(手工删了 / 从备份恢复过 sidecar):这条记录
|
|
158
|
+
// 只是残留,清掉即算删除完成,否则它会永久占住这个名字。
|
|
159
|
+
if (!await pathExists(disabled.path)) {
|
|
160
|
+
await deps.store.removeDisabled(name)
|
|
161
|
+
await deps.store.removeSkillFromTags(name)
|
|
162
|
+
deps.invalidate?.()
|
|
163
|
+
writeJson(res, 200, { ok: true, name, path: disabled.path } satisfies SkillDeleteResponse)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
|
|
167
|
+
// (trashSkill 认两种发现文件名,目录包按整目录搬)。
|
|
168
|
+
trashResult = await trashSkill(disabled.path)
|
|
169
|
+
} else {
|
|
161
170
|
writeError(res, resolved.status, resolved.error); return
|
|
162
171
|
}
|
|
163
172
|
}
|
|
@@ -196,13 +205,28 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
196
205
|
if (body.enabled === true) {
|
|
197
206
|
const record = await deps.store.getDisabled(name)
|
|
198
207
|
if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
|
|
208
|
+
// 记录里的路径可能已经失效(文件被手工删/搬走)。先在同 root 下找现存的
|
|
209
|
+
// 禁用占位:找得到就把记录指向它,找不到说明这条记录是残留 —— 清掉并把
|
|
210
|
+
// 名字还给用户,而不是让「启用」永远失败把名字锁死。
|
|
211
|
+
let disabledPath = record.path
|
|
212
|
+
if (!await pathExists(disabledPath)) {
|
|
213
|
+
// root 从记录路径反推,不直接信 sidecar 里的 root 字段(可能是损坏数据)。
|
|
214
|
+
const root = rootOfPath(disabledPath, homeOf(deps))
|
|
215
|
+
const occupancy = root === undefined ? undefined : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name)
|
|
216
|
+
if (occupancy === undefined) { await deps.store.removeDisabled(name)
|
|
217
|
+
deps.invalidate?.()
|
|
218
|
+
writeError(res, 409, 'disabled record was stale (its file no longer exists); the record has been removed — retry now')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
disabledPath = occupancy
|
|
222
|
+
}
|
|
199
223
|
try {
|
|
200
|
-
await enableSkill(
|
|
224
|
+
await enableSkill(disabledPath)
|
|
201
225
|
} catch (error) {
|
|
202
226
|
// The renamed file may have vanished (external cleanup); report
|
|
203
227
|
// precisely instead of a generic 500, like toggle-batch does.
|
|
204
228
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
205
|
-
writeError(res, 409, 'disabled skill file is missing on disk: ' +
|
|
229
|
+
writeError(res, 409, 'disabled skill file is missing on disk: ' + disabledPath)
|
|
206
230
|
return
|
|
207
231
|
}
|
|
208
232
|
throw error
|
|
@@ -275,12 +299,16 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
275
299
|
const existing = await deps.skills.get(name)
|
|
276
300
|
if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
|
|
277
301
|
if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
|
|
278
|
-
//
|
|
279
|
-
// frontmatter
|
|
280
|
-
//
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
writeError(res, 409, 'skill
|
|
302
|
+
// 建档前先看磁盘:三种占位都会让新目录与旧文件并存成同名两形态 ——
|
|
303
|
+
// 已启用的发现文件(frontmatter 坏了因此注册表看不见)、已禁用的占位
|
|
304
|
+
// (目录包或扁平)、以及没有 SKILL.md 的空壳目录(正是发现诊断在报的东西)。
|
|
305
|
+
const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name)
|
|
306
|
+
if (occupancy.enabled.length > 0) {
|
|
307
|
+
writeError(res, 409, 'a skill file already occupies this name on disk: ' + name + ' (check the discovery diagnostics)')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
|
|
311
|
+
writeError(res, 409, 'name is occupied by a disabled placeholder or an empty directory on disk: ' + name + ' (check the discovery diagnostics)')
|
|
284
312
|
return
|
|
285
313
|
}
|
|
286
314
|
const path = await createSkill(root, name, readString(body, 'description'), homeOf(deps))
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { mkdir } from 'node:fs/promises'
|
|
7
|
-
import { dirname, join } from 'node:path'
|
|
8
7
|
import { randomUUID } from 'node:crypto'
|
|
9
8
|
import {
|
|
10
9
|
SKILL_HUB_API,
|
|
@@ -28,12 +27,11 @@ import {
|
|
|
28
27
|
skillManifest,
|
|
29
28
|
skillPathIn,
|
|
30
29
|
} from '../repo.ts'
|
|
31
|
-
import {
|
|
30
|
+
import { occupancyOf, rootPath } from '../skillfs.ts'
|
|
32
31
|
import { errorText } from '../error-text.ts'
|
|
33
32
|
import {
|
|
34
33
|
homeOf,
|
|
35
34
|
knownSkillNames,
|
|
36
|
-
pathExists,
|
|
37
35
|
queryParam,
|
|
38
36
|
readString,
|
|
39
37
|
readStrings,
|
|
@@ -42,29 +40,20 @@ import {
|
|
|
42
40
|
type RouteSpec,
|
|
43
41
|
type SkillHubRouteDeps,
|
|
44
42
|
} from './helpers.ts'
|
|
45
|
-
import { gcImportJobs, importJobs, type ImportJob } from './route-state.ts'
|
|
43
|
+
import { gcImportJobs, importJobs, retireNamePlaceholders, type ImportJob } from './route-state.ts'
|
|
46
44
|
|
|
47
45
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
|
|
47
|
+
* 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
|
|
48
|
+
* 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
|
|
49
|
+
* 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
|
|
50
|
+
* 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
* 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
|
|
52
|
+
* 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
|
|
54
53
|
*/
|
|
55
54
|
async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
|
|
56
|
-
|
|
57
|
-
|
|
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 走同一个回收站落盘规则(moveIntoTrash),恢复流程依赖它。
|
|
64
|
-
const { path: target } = await moveIntoTrash(sourceToTrash)
|
|
65
|
-
await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
|
|
66
|
-
await deps.store.removeDisabled(name)
|
|
67
|
-
return true
|
|
55
|
+
if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false
|
|
56
|
+
return (await retireNamePlaceholders(deps, targetRoot, name)) !== undefined
|
|
68
57
|
}
|
|
69
58
|
|
|
70
59
|
/** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
@@ -192,10 +181,11 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
192
181
|
job.done += 1
|
|
193
182
|
continue
|
|
194
183
|
}
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
184
|
+
// 磁盘上有同名占位。若它只是一个已禁用的占位(目录包或扁平)或一个空壳
|
|
185
|
+
// 目录,先把旧东西移入回收站再继续导入;有启用的同名技能时退回「跳过」
|
|
186
|
+
// (issue #16)。
|
|
187
|
+
const occupancy = await occupancyOf(targetRoot, entry.name)
|
|
188
|
+
if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
|
|
199
189
|
let cleared = false
|
|
200
190
|
try {
|
|
201
191
|
cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
|
|
@@ -208,7 +198,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
208
198
|
job.done += 1
|
|
209
199
|
continue
|
|
210
200
|
}
|
|
211
|
-
//
|
|
201
|
+
// 占位已经进了回收站(可恢复)。即使随后的下载失败也必须如实
|
|
212
202
|
// 上报,否则用户不知道自己的占位被挪走了。
|
|
213
203
|
job.replaced.push(entry.name)
|
|
214
204
|
}
|
|
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { IMPORT_JOB_MAX, gcImportJobs, importJobs,
|
|
5
|
+
import { IMPORT_JOB_MAX, gcImportJobs, importJobs, replaceInPlace, type ImportJob } from './route-state.ts'
|
|
6
6
|
|
|
7
7
|
/** One import job with sane defaults; override what the test cares about. */
|
|
8
8
|
function job(jobId: string, createdAt: number, status: ImportJob['status'] = 'done'): ImportJob {
|
|
@@ -50,7 +50,7 @@ describe('gcImportJobs', () => {
|
|
|
50
50
|
})
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
describe('
|
|
53
|
+
describe('replaceInPlace', () => {
|
|
54
54
|
let dir: string
|
|
55
55
|
|
|
56
56
|
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
|
|
@@ -60,7 +60,7 @@ describe('replaceSkillDir', () => {
|
|
|
60
60
|
const target = join(dir, 'skill')
|
|
61
61
|
await mkdir(target, { recursive: true })
|
|
62
62
|
await writeFile(join(target, 'old.txt'), 'old', 'utf8')
|
|
63
|
-
await
|
|
63
|
+
await replaceInPlace(target, async () => {
|
|
64
64
|
// 下载方负责重建目标目录(downloadRepoSkill 就是这么做的)。
|
|
65
65
|
await mkdir(target, { recursive: true })
|
|
66
66
|
await writeFile(join(target, 'new.txt'), 'new', 'utf8')
|
|
@@ -76,7 +76,7 @@ describe('replaceSkillDir', () => {
|
|
|
76
76
|
const target = join(dir, 'skill')
|
|
77
77
|
await mkdir(target, { recursive: true })
|
|
78
78
|
await writeFile(join(target, 'old.txt'), 'old', 'utf8')
|
|
79
|
-
await expect(
|
|
79
|
+
await expect(replaceInPlace(target, async () => { throw new Error('download failed') })).rejects.toThrow('download failed')
|
|
80
80
|
expect(await readFile(join(target, 'old.txt'), 'utf8')).toBe('old')
|
|
81
81
|
expect((await readdir(dir)).filter((name) => name.startsWith('.'))).toEqual([])
|
|
82
82
|
})
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { rm, rename } from 'node:fs/promises'
|
|
7
7
|
import { basename, dirname, join } from 'node:path'
|
|
8
8
|
import { errorText } from '../error-text.ts'
|
|
9
|
+
import { moveIntoTrash, occupancyOf, trashSourceOf } from '../skillfs.ts'
|
|
9
10
|
import type { SkillHubRouteDeps } from './helpers.ts'
|
|
10
11
|
|
|
11
12
|
/** Minimum interval between network update checks per source (GitHub rate limits). */
|
|
@@ -79,21 +80,55 @@ export function gcImportJobs(): void {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Replace whatever currently occupies a skill slot with a fresh download;
|
|
85
|
+
* restores the previous state on failure.
|
|
86
|
+
*
|
|
87
|
+
* `target` is the entry to move aside — a bundle **directory** or a flat
|
|
88
|
+
* `<name>.md` file, whichever the download is replacing (the rename/rm below
|
|
89
|
+
* are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
|
|
90
|
+
* so even a failed cleanup can never register as a duplicate skill.
|
|
91
|
+
*/
|
|
92
|
+
export async function replaceInPlace(target: string, download: () => Promise<unknown>): Promise<void> {
|
|
93
|
+
const backup = join(dirname(target), '.' + basename(target) + '.sync-bak-' + Date.now())
|
|
94
|
+
await rename(target, backup)
|
|
89
95
|
try {
|
|
90
96
|
await download()
|
|
91
97
|
} catch (error) {
|
|
92
|
-
await rename(backup,
|
|
93
|
-
//
|
|
94
|
-
console.warn('[dsh-skill-hub] restoring ' +
|
|
98
|
+
await rename(backup, target).catch((rollbackError) => {
|
|
99
|
+
// 回滚失败会让技能只剩点前缀的备份:必须留痕。
|
|
100
|
+
console.warn('[dsh-skill-hub] restoring ' + target + ' from ' + backup + ' failed:', errorText(rollbackError))
|
|
95
101
|
})
|
|
96
102
|
throw error
|
|
97
103
|
}
|
|
98
104
|
await rm(backup, { recursive: true, force: true })
|
|
99
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
|
|
109
|
+
* 只动这两类,**启用的技能一律不碰**:
|
|
110
|
+
* - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
|
|
111
|
+
* - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
|
|
112
|
+
*
|
|
113
|
+
* 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
|
|
114
|
+
* 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
|
|
115
|
+
*
|
|
116
|
+
* @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
|
|
117
|
+
*/
|
|
118
|
+
export async function retireNamePlaceholders(deps: SkillHubRouteDeps, base: string, name: string): Promise<string[] | undefined> {
|
|
119
|
+
const occupancy = await occupancyOf(base, name)
|
|
120
|
+
// trashSourceOf 让「目录包禁用件」与「空壳目录」自然去重(同一个目录只搬一次)。
|
|
121
|
+
const sources = [...new Set([
|
|
122
|
+
...occupancy.disabled.map((path) => trashSourceOf(path)),
|
|
123
|
+
...(occupancy.bareDir !== undefined ? [occupancy.bareDir] : []),
|
|
124
|
+
])]
|
|
125
|
+
if (sources.length === 0) return undefined
|
|
126
|
+
const trashed: string[] = []
|
|
127
|
+
for (const source of sources) {
|
|
128
|
+
const moved = await moveIntoTrash(source)
|
|
129
|
+
await deps.store.addTrash({ name, path: moved.path, movedAt: Date.now(), sourcePath: moved.source })
|
|
130
|
+
trashed.push(moved.path)
|
|
131
|
+
}
|
|
132
|
+
await deps.store.removeDisabled(name)
|
|
133
|
+
return trashed
|
|
134
|
+
}
|