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.
@@ -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
+ })
@@ -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
+ }
@@ -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