dsh-skill-hub 0.3.12 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +4 -2
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/client.js +85 -41
- package/lib/client.js.map +1 -1
- package/lib/index.js +95 -0
- package/lib/types/client/grouping.d.ts +18 -1
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/skillfs/scan.d.ts +7 -0
- package/package.json +29 -28
- package/src/client/grouping.test.ts +35 -2
- package/src/client/grouping.ts +38 -1
- package/src/client/panel/PanelDialogs.tsx +8 -2
- package/src/client/panel/SourcesView.tsx +12 -12
- package/src/client/panel/dialogs.tsx +45 -18
- package/src/index.ts +11 -0
- package/src/reconcile.test.ts +76 -0
- package/src/reconcile.ts +62 -0
- package/src/skillfs/scan.ts +38 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { reconcileDisabledSkills } from './reconcile.ts'
|
|
6
|
+
import { SkillHubStore, statePath } from './store.ts'
|
|
7
|
+
|
|
8
|
+
describe('reconcileDisabledSkills', () => {
|
|
9
|
+
let dir: string
|
|
10
|
+
let home: string
|
|
11
|
+
let agentsHome: string
|
|
12
|
+
let previousAgentsHome: string | undefined
|
|
13
|
+
let store: SkillHubStore
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-reconcile-'))
|
|
17
|
+
home = join(dir, 'home')
|
|
18
|
+
agentsHome = join(dir, 'agents')
|
|
19
|
+
await mkdir(join(home, 'skills'), { recursive: true })
|
|
20
|
+
await mkdir(join(agentsHome, 'skills'), { recursive: true })
|
|
21
|
+
previousAgentsHome = process.env.DSH_AGENTS_HOME
|
|
22
|
+
process.env.DSH_AGENTS_HOME = agentsHome
|
|
23
|
+
store = new SkillHubStore(statePath(home))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
if (previousAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
|
|
28
|
+
else process.env.DSH_AGENTS_HOME = previousAgentsHome
|
|
29
|
+
await rm(dir, { recursive: true, force: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('rebuilds a missing record for a disabled directory bundle', async () => {
|
|
33
|
+
await mkdir(join(home, 'skills', 'paused-skill'))
|
|
34
|
+
await writeFile(
|
|
35
|
+
join(home, 'skills', 'paused-skill', 'SKILL.md.disabled'),
|
|
36
|
+
'---\nname: paused-skill\ndescription: Paused bundle\n---\n\nBody',
|
|
37
|
+
'utf8',
|
|
38
|
+
)
|
|
39
|
+
const added = await reconcileDisabledSkills(store, home)
|
|
40
|
+
expect(added).toHaveLength(1)
|
|
41
|
+
expect(await store.getDisabled('paused-skill')).toMatchObject({
|
|
42
|
+
name: 'paused-skill',
|
|
43
|
+
description: 'Paused bundle',
|
|
44
|
+
path: join(home, 'skills', 'paused-skill', 'SKILL.md.disabled'),
|
|
45
|
+
root: 'user-dsh',
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rebuilds records for flat files in both user roots', async () => {
|
|
50
|
+
await writeFile(join(home, 'skills', 'flat-skill.md.disabled'), '---\nname: flat-skill\ndescription: Flat one\n---', 'utf8')
|
|
51
|
+
await writeFile(join(agentsHome, 'skills', 'agent-skill.md.disabled'), '---\nname: agent-skill\ndescription: Agent one\n---', 'utf8')
|
|
52
|
+
const added = await reconcileDisabledSkills(store, home)
|
|
53
|
+
expect(added.map((entry) => entry.name).sort()).toEqual(['agent-skill', 'flat-skill'])
|
|
54
|
+
expect((await store.getDisabled('agent-skill'))?.root).toBe('user-agents')
|
|
55
|
+
expect((await store.getDisabled('flat-skill'))?.root).toBe('user-dsh')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps existing records untouched and skips invalid disabled files', async () => {
|
|
59
|
+
const recordPath = join(home, 'skills', 'known-skill', 'SKILL.md.disabled')
|
|
60
|
+
await mkdir(join(home, 'skills', 'known-skill'))
|
|
61
|
+
await writeFile(recordPath, '---\nname: known-skill\ndescription: Fresh text\n---', 'utf8')
|
|
62
|
+
await store.addDisabled({ name: 'known-skill', description: 'Original', path: recordPath, root: 'user-dsh', disabledAt: 1 })
|
|
63
|
+
await writeFile(join(home, 'skills', 'broken.md.disabled'), '# no frontmatter', 'utf8')
|
|
64
|
+
|
|
65
|
+
expect(await reconcileDisabledSkills(store, home)).toEqual([])
|
|
66
|
+
expect((await store.getDisabled('known-skill'))?.description).toBe('Original')
|
|
67
|
+
expect(await store.getDisabled('broken')).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is idempotent across runs', async () => {
|
|
71
|
+
await writeFile(join(home, 'skills', 'flat-skill.md.disabled'), '---\nname: flat-skill\ndescription: Flat one\n---', 'utf8')
|
|
72
|
+
expect(await reconcileDisabledSkills(store, home)).toHaveLength(1)
|
|
73
|
+
expect(await reconcileDisabledSkills(store, home)).toEqual([])
|
|
74
|
+
expect(await store.listDisabled()).toHaveLength(1)
|
|
75
|
+
})
|
|
76
|
+
})
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar reconcile for hub-disabled skills.
|
|
3
|
+
*
|
|
4
|
+
* The catalog merges two sources: enabled skills come from the provider
|
|
5
|
+
* (SKILL.md discovery), disabled skills come from the sidecar's `disabled`
|
|
6
|
+
* records. The two can drift — a sidecar restored from a backup, a hand
|
|
7
|
+
* edit, or an older build can leave a `SKILL.md.disabled` file on disk with
|
|
8
|
+
* no record, which makes the skill invisible in every view (it is neither
|
|
9
|
+
* enabled nor disabled) and leaves its origin collection rendering as an
|
|
10
|
+
* empty shell. This walk rebuilds the missing records from disk at startup.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
14
|
+
import type { DisabledSkill } from './protocol.ts'
|
|
15
|
+
import { parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
|
|
16
|
+
import { dshHome } from './store.ts'
|
|
17
|
+
|
|
18
|
+
/** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
|
|
19
|
+
export interface DisabledReconcileStore {
|
|
20
|
+
listDisabled(): Promise<DisabledSkill[]>
|
|
21
|
+
addDisabled(entry: DisabledSkill): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Add sidecar disabled records for every `.disabled` discovery file that has
|
|
26
|
+
* none yet. Existing records (matched by path or by name) win, so this never
|
|
27
|
+
* rewrites user data; unreadable or invalid files are skipped (they surface
|
|
28
|
+
* in the diagnostics scan instead). Returns the records added.
|
|
29
|
+
*/
|
|
30
|
+
export async function reconcileDisabledSkills(store: DisabledReconcileStore, home = dshHome()): Promise<DisabledSkill[]> {
|
|
31
|
+
const known = await store.listDisabled()
|
|
32
|
+
const knownNames = new Set(known.map((entry) => entry.name))
|
|
33
|
+
const knownPaths = new Set(known.map((entry) => entry.path))
|
|
34
|
+
const added: DisabledSkill[] = []
|
|
35
|
+
for (const root of WRITABLE_ROOTS) {
|
|
36
|
+
for (const path of await scanDisabledRoot(rootPath(root, home))) {
|
|
37
|
+
if (knownPaths.has(path)) continue
|
|
38
|
+
let text: string
|
|
39
|
+
try {
|
|
40
|
+
text = await readFile(path, 'utf8')
|
|
41
|
+
} catch {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const parsed = parseFrontmatter(text)
|
|
45
|
+
if ('error' in parsed) continue
|
|
46
|
+
const { name, description } = parsed.value
|
|
47
|
+
if (knownNames.has(name)) continue
|
|
48
|
+
let disabledAt = 0
|
|
49
|
+
try {
|
|
50
|
+
disabledAt = (await stat(path)).mtimeMs
|
|
51
|
+
} catch {
|
|
52
|
+
continue // 扫描途中被删除
|
|
53
|
+
}
|
|
54
|
+
const record: DisabledSkill = { name, description, path, root, disabledAt }
|
|
55
|
+
await store.addDisabled(record)
|
|
56
|
+
knownNames.add(name)
|
|
57
|
+
knownPaths.add(path)
|
|
58
|
+
added.push(record)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return added
|
|
62
|
+
}
|
package/src/skillfs/scan.ts
CHANGED
|
@@ -53,6 +53,44 @@ export function listSkillEntries(root: WritableRoot, home = dshHome()): Promise<
|
|
|
53
53
|
return scanRoot(rootPath(root, home))
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Scan one skills root for hub-disabled discovery files: directory bundles
|
|
58
|
+
* renamed to SKILL.md.disabled and flat <name>.md.disabled files. Used by
|
|
59
|
+
* the startup reconcile to rebuild sidecar records that were lost, which
|
|
60
|
+
* would otherwise leave the skill invisible in every view.
|
|
61
|
+
*/
|
|
62
|
+
export async function scanDisabledRoot(base: string): Promise<string[]> {
|
|
63
|
+
const paths: string[] = []
|
|
64
|
+
let names: string[]
|
|
65
|
+
try {
|
|
66
|
+
names = await readdir(base)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return paths
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
if (name.startsWith('.')) continue
|
|
73
|
+
const absolute = join(base, name)
|
|
74
|
+
let stats
|
|
75
|
+
try {
|
|
76
|
+
stats = await stat(absolute)
|
|
77
|
+
} catch {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (stats.isDirectory()) {
|
|
81
|
+
const candidate = join(absolute, 'SKILL.md.disabled')
|
|
82
|
+
try {
|
|
83
|
+
if ((await stat(candidate)).isFile()) paths.push(candidate)
|
|
84
|
+
} catch {
|
|
85
|
+
// 目录里没有禁用的发现文件,跳过
|
|
86
|
+
}
|
|
87
|
+
} else if (name.endsWith('.md.disabled') && name !== 'SKILL.md.disabled') {
|
|
88
|
+
paths.push(absolute)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return paths.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
92
|
+
}
|
|
93
|
+
|
|
56
94
|
/** UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
|
|
57
95
|
export interface SkillInterface {
|
|
58
96
|
displayName?: string
|