dsh-skill-hub 0.3.11 → 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 +132 -2
- package/lib/types/client/grouping.d.ts +18 -1
- package/lib/types/error-text.d.ts +4 -1
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/skillfs/scan.d.ts +7 -0
- package/package.json +35 -29
- 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/error-text.test.ts +45 -0
- package/src/error-text.ts +44 -2
- 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
package/src/error-text.test.ts
CHANGED
|
@@ -16,4 +16,49 @@ describe('errorText', () => {
|
|
|
16
16
|
it('uses an empty message when the Error has none', () => {
|
|
17
17
|
expect(errorText(new Error(''))).toBe('')
|
|
18
18
|
})
|
|
19
|
+
|
|
20
|
+
it('appends a cause message to a fetch failure', () => {
|
|
21
|
+
const error = new TypeError('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND api.github.com') })
|
|
22
|
+
expect(errorText(error)).toBe('fetch failed (getaddrinfo ENOTFOUND api.github.com)')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('appends an error code when the cause message does not contain it', () => {
|
|
26
|
+
const cause = Object.assign(new Error('Connect Timeout Error'), { code: 'UND_ERR_CONNECT_TIMEOUT' })
|
|
27
|
+
expect(errorText(new TypeError('fetch failed', { cause }))).toBe('fetch failed (Connect Timeout Error [UND_ERR_CONNECT_TIMEOUT])')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('does not duplicate a code already present in the cause message', () => {
|
|
31
|
+
const cause = Object.assign(new Error('getaddrinfo ENOTFOUND api.github.com'), { code: 'ENOTFOUND' })
|
|
32
|
+
expect(errorText(new TypeError('fetch failed', { cause }))).toBe('fetch failed (getaddrinfo ENOTFOUND api.github.com)')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('walks nested cause chains', () => {
|
|
36
|
+
const inner = new Error('inner reason')
|
|
37
|
+
const outer = new Error('outer', { cause: inner })
|
|
38
|
+
expect(errorText(new TypeError('fetch failed', { cause: outer }))).toBe('fetch failed (outer; inner reason)')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('expands an AggregateError cause', () => {
|
|
42
|
+
const cause = new AggregateError([
|
|
43
|
+
new Error('connect ECONNREFUSED 127.0.0.1:443'),
|
|
44
|
+
new Error('connect ECONNREFUSED [::1]:443'),
|
|
45
|
+
])
|
|
46
|
+
expect(errorText(new TypeError('fetch failed', { cause }))).toBe('fetch failed (connect ECONNREFUSED 127.0.0.1:443; connect ECONNREFUSED [::1]:443)')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('stops at self-referencing cause chains', () => {
|
|
50
|
+
const error = new Error('fetch failed')
|
|
51
|
+
error.cause = error
|
|
52
|
+
expect(errorText(error)).toBe('fetch failed')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('stringifies non-Error causes', () => {
|
|
56
|
+
const error = new Error('fetch failed')
|
|
57
|
+
error.cause = 'socket closed'
|
|
58
|
+
expect(errorText(error)).toBe('fetch failed (socket closed)')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns the cause detail alone when the top message is empty', () => {
|
|
62
|
+
expect(errorText(new Error('', { cause: new Error('boom') }))).toBe('boom')
|
|
63
|
+
})
|
|
19
64
|
})
|
package/src/error-text.ts
CHANGED
|
@@ -2,9 +2,51 @@
|
|
|
2
2
|
* 错误文案收敛:宿主各处把 unknown 异常转成一行可读文字。原先这条表达式
|
|
3
3
|
* 在宿主侧内联了 22 次(`error instanceof Error ? error.message : String(error)`),
|
|
4
4
|
* 现在统一走这里。浏览器半有自己的 `helpers.errorMessage`,两半互不引用。
|
|
5
|
+
*
|
|
6
|
+
* Error 的 `cause` 链(undici 的 `fetch failed`、AggregateError 的多地址
|
|
7
|
+
* 失败、TLS 证书错误等)以括号附录带出,避免只剩笼统的顶层 message。
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
|
-
/**
|
|
10
|
+
/** cause 链最大展开深度,防自引用/超深链。 */
|
|
11
|
+
const MAX_CAUSE_DEPTH = 4
|
|
12
|
+
|
|
13
|
+
/** 合成一条 cause 细节;code 已在 message 里则不重复。 */
|
|
14
|
+
function causeDetail(message: string, code: string): string {
|
|
15
|
+
const text = message.trim()
|
|
16
|
+
if (code === '') return text
|
|
17
|
+
if (text === '') return '[' + code + ']'
|
|
18
|
+
return text.includes(code) ? text : text + ' [' + code + ']'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 深度受限地收集 cause 链细节(AggregateError 展开其 errors)。 */
|
|
22
|
+
function collectCauseDetails(cause: unknown, depth: number, seen: Set<unknown>, out: string[]): void {
|
|
23
|
+
if (depth > MAX_CAUSE_DEPTH || cause === null || cause === undefined || seen.has(cause)) return
|
|
24
|
+
seen.add(cause)
|
|
25
|
+
if (cause instanceof Error) {
|
|
26
|
+
const errors = (cause as { errors?: unknown }).errors
|
|
27
|
+
if (Array.isArray(errors)) {
|
|
28
|
+
for (const item of errors) collectCauseDetails(item, depth + 1, seen, out)
|
|
29
|
+
}
|
|
30
|
+
const rawCode: unknown = (cause as { code?: unknown }).code
|
|
31
|
+
const code = typeof rawCode === 'string' ? rawCode : ''
|
|
32
|
+
const detail = causeDetail(cause.message, code)
|
|
33
|
+
if (detail !== '') out.push(detail)
|
|
34
|
+
collectCauseDetails(cause.cause, depth + 1, seen, out)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const detail = causeDetail(String(cause), '')
|
|
38
|
+
if (detail !== '') out.push(detail)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 从 unknown 异常取出可读文案;非 Error 值用 String 兜底,cause 附在括号里。 */
|
|
8
42
|
export function errorText(error: unknown): string {
|
|
9
|
-
|
|
43
|
+
if (!(error instanceof Error)) return String(error)
|
|
44
|
+
const details: string[] = []
|
|
45
|
+
collectCauseDetails(error.cause, 1, new Set(), details)
|
|
46
|
+
for (let index = details.length - 1; index >= 0; index -= 1) {
|
|
47
|
+
if (details[index] === error.message || details.indexOf(details[index]) !== index) details.splice(index, 1)
|
|
48
|
+
}
|
|
49
|
+
if (details.length === 0) return error.message
|
|
50
|
+
const suffix = details.join('; ')
|
|
51
|
+
return error.message === '' ? suffix : error.message + ' (' + suffix + ')'
|
|
10
52
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { SkillHubProvider } from './provider.ts'
|
|
|
21
21
|
import { makeRoutes } from './routes.ts'
|
|
22
22
|
import { createSkillStatsReader, asPersistenceSeam, type SessionPersistenceLike, type SessionQueryLike, type SkillStatsReader } from './stats.ts'
|
|
23
23
|
import { SkillHubStore } from './store.ts'
|
|
24
|
+
import { reconcileDisabledSkills } from './reconcile.ts'
|
|
24
25
|
import { cleanupLeftoverImportDirs, setGithubToken } from './repo.ts'
|
|
25
26
|
import { dshHome } from './store.ts'
|
|
26
27
|
import { join } from 'node:path'
|
|
@@ -234,6 +235,16 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
234
235
|
ctx.logger.warn('[dsh-skill-hub] startup cleanup failed', error)
|
|
235
236
|
}
|
|
236
237
|
}
|
|
238
|
+
// 对账:磁盘上已有 .disabled、sidecar 却无记录(状态文件被恢复/手改、旧版本
|
|
239
|
+
// 遗留)时补记录,否则这些技能在面板里既不算启用也不算禁用,来源组空壳。
|
|
240
|
+
try {
|
|
241
|
+
const reconciled = await reconcileDisabledSkills(store, home)
|
|
242
|
+
if (reconciled.length > 0) {
|
|
243
|
+
ctx.logger.info(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(', ')}`)
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
ctx.logger.warn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
|
|
247
|
+
}
|
|
237
248
|
})()
|
|
238
249
|
|
|
239
250
|
void (async () => {
|
|
@@ -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
|