@menghuan1918/dsh-apollo 0.0.0-stage → 0.3.0

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.
Files changed (47) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +232 -2
  3. package/cordis.patch.yml +78 -0
  4. package/docs/DEVELOPMENT.md +629 -0
  5. package/package.json +75 -4
  6. package/presets/eng/NOTICE +2 -0
  7. package/presets/eng/plugins/exit-guard/index.js +111 -0
  8. package/presets/eng/plugins/exit-guard/package.json +7 -0
  9. package/presets/eng/plugins/mode-instructions/index.js +48 -0
  10. package/presets/eng/plugins/mode-instructions/package.json +7 -0
  11. package/presets/eng/plugins/wait-subagent/index.js +228 -0
  12. package/presets/eng/plugins/wait-subagent/package.json +7 -0
  13. package/presets/eng-noask/NOTICE +2 -0
  14. package/presets/eng-noask/plugins/exit-guard/index.js +111 -0
  15. package/presets/eng-noask/plugins/exit-guard/package.json +7 -0
  16. package/presets/eng-noask/plugins/mode-instructions/index.js +48 -0
  17. package/presets/eng-noask/plugins/mode-instructions/package.json +7 -0
  18. package/presets/eng-noask/plugins/wait-subagent/index.js +228 -0
  19. package/presets/eng-noask/plugins/wait-subagent/package.json +7 -0
  20. package/presets/eng-noask.patch.yml +341 -0
  21. package/presets/eng.patch.yml +341 -0
  22. package/skills/brainstorming/SKILL.md +90 -0
  23. package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
  24. package/skills/code-review/SKILL.md +90 -0
  25. package/skills/dev/SKILL.md +43 -0
  26. package/skills/diagnosing-bugs/SKILL.md +132 -0
  27. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +55 -0
  28. package/skills/exec/SKILL.md +91 -0
  29. package/skills/handoff/SKILL.md +23 -0
  30. package/skills/main-agent-driven-development/SKILL.md +64 -0
  31. package/skills/tdd/SKILL.md +34 -0
  32. package/skills/tdd/mocking.md +59 -0
  33. package/skills/tdd/tests.md +77 -0
  34. package/src/cli/claude.js +166 -0
  35. package/src/cli/codex.js +164 -0
  36. package/src/cli/jsonl.js +216 -0
  37. package/src/cli-adapter.js +198 -0
  38. package/src/cli-sessions.js +69 -0
  39. package/src/client.js +815 -0
  40. package/src/config.js +301 -0
  41. package/src/dsh-home.js +38 -0
  42. package/src/index.js +181 -0
  43. package/src/panel.js +452 -0
  44. package/src/settings.js +191 -0
  45. package/src/subagents.js +199 -0
  46. package/third-party/LICENSE-MIT-dsh-standard-preset +21 -0
  47. package/third-party/NOTICE-mattpocock-skills +21 -0
package/src/config.js ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * dsh-apollo (npm: @menghuan1918/dsh-apollo) —— 正式插件配置(Config)与配置读取/规整。
3
+ *
4
+ * 这里是本包「设置」的唯一事实源。宿主行 `apollo-skills` 由 `src/index.js`
5
+ * 重新导出本文件的 `Config`(schemastery schema),DSH 据此校验并解析行配置:
6
+ *
7
+ * - 标了 `.volatile()` 的字段在配置变更时**不重挂插件**——Loader 只把新值写回
8
+ * 运行中 fiber 的稳定引用(`createVolatile` 协议 + `loader/volatile-update`),
9
+ * 消费方每次读取都拿到最新值。退出守卫按回合读取、CLI 子代理路由按次读取,
10
+ * 因此这些开关无需重启或重挂即可即时生效。
11
+ * - 普通字段(`trustedHosts`、`declareOwnsHost`)是**部署标志**:只在行配置里
12
+ * 声明,改动会让 Loader 正常重挂本行(重挂才需要重新读)。
13
+ *
14
+ * 可编辑范围(volatile,即 dsh-settings 投影出的行配置表单字段):
15
+ * `exitGuard`、`subagents.eng`、`subagents.global`、`subagents.codex.*`、
16
+ * `subagents.claude.*`。表单写入只能落在 volatile 路径上(dsh-settings 会拒绝
17
+ * 其他路径),所以部署标志不会从网页上被改写。
18
+ *
19
+ * 持久化:DSH 的 configEditor 把表单写入落到活动 profile 的 cordis.patch.yml
20
+ * (本行 id 的 `config` 覆盖层),不再有 `<dshHome>/eng.json` 写入路径;旧文件
21
+ * 只由 `src/settings.js` 一次性只读导入。
22
+ *
23
+ * 读取入口:
24
+ * - `normalizeConfig(value)`——把任意来源(volatile 引用、解析后的配置、旧
25
+ * JSON、残缺对象)规整成**普通 JSON**,形状 EXACT 且与 `DEFAULT_CONFIG`
26
+ * 一致;一切非法值回退默认,绝不抛错。
27
+ * - `currentConfig(ctx, fallback)`——经 configEditor 找到本行的活 fiber,读取
28
+ * 其已解析配置并规整(volatile 引用按当前值展开);找不到时退回 `fallback`
29
+ * (默认形状)。预设本地守卫与 CLI 路由用它读活配置。
30
+ */
31
+
32
+ import z from '@deepseek-ai/schemastery'
33
+
34
+ /** 本包在 profile roster 里的行 id;也是 dsh-settings 的命名空间(= Loader entry id)。 */
35
+ export const ENTRY_ID = 'apollo-skills'
36
+ /** 包的 npm 名(scoped)。 */
37
+ export const PACKAGE_NAME = '@menghuan1918/dsh-apollo'
38
+ /**
39
+ * 旧 unscoped 包名。只用于旧 profile 的 config slot 兼容(浏览器半边并排注册
40
+ * 旧键);新的 bundle 行、模块 specifier 与 client closure id 一律用 PACKAGE_NAME。
41
+ */
42
+ export const LEGACY_PACKAGE_NAME = 'dsh-apollo'
43
+ /** 浏览器半边注册 `plugins.row.config` 用的键:`<包名>#<行 id>`。 */
44
+ export const ROW_CONFIG_KEY = `${PACKAGE_NAME}#${ENTRY_ID}`
45
+ /** 旧包名对应的行配置键;仅与 {@link ROW_CONFIG_KEY} 并排注册以兼容旧 profile。 */
46
+ export const LEGACY_ROW_CONFIG_KEY = `${LEGACY_PACKAGE_NAME}#${ENTRY_ID}`
47
+
48
+ /** Codex CLI 沙箱/权限档(对应 `codex --sandbox <值>` 一族)。 */
49
+ export const CODEX_PERMISSION_MODES = Object.freeze(['read-only', 'workspace-write', 'danger-full-access'])
50
+ /** Claude Code CLI 权限档(`claude --permission-mode` 的实测 help 取值;没有 `default`)。 */
51
+ export const CLAUDE_PERMISSION_MODES = Object.freeze(['plan', 'manual', 'dontAsk', 'acceptEdits', 'auto', 'bypassPermissions'])
52
+
53
+ /** 默认可执行文件名(**不写死本机路径**:从 PATH 解析,找不到由用户在设置里手输)。 */
54
+ export const DEFAULT_CODEX_EXECUTABLE = 'codex'
55
+ /** @see DEFAULT_CODEX_EXECUTABLE */
56
+ export const DEFAULT_CLAUDE_EXECUTABLE = 'claude'
57
+
58
+ /** cosmokit volatile 引用的写入符号(全局符号注册表,跨包副本一致)。 */
59
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
60
+
61
+ /** 后端默认值表(两个 CLI 的差异只有可执行文件名、权限档与 profile 字段)。 */
62
+ const BACKENDS = Object.freeze({
63
+ codex: Object.freeze({
64
+ executable: DEFAULT_CODEX_EXECUTABLE,
65
+ permissionMode: 'read-only',
66
+ permissionModes: CODEX_PERMISSION_MODES,
67
+ profile: true,
68
+ }),
69
+ claude: Object.freeze({
70
+ executable: DEFAULT_CLAUDE_EXECUTABLE,
71
+ permissionMode: 'plan',
72
+ permissionModes: CLAUDE_PERMISSION_MODES,
73
+ profile: false,
74
+ }),
75
+ })
76
+
77
+ /** 一个后端的一个模型条目:id + 允许的 effort 列表 + 默认 effort。 */
78
+ const ModelSchema = z.object({
79
+ id: z.string().required(),
80
+ efforts: z.array(z.string()).default([]),
81
+ defaultEffort: z.string().default(''),
82
+ })
83
+
84
+ /** 一个 CLI 后端的可编辑配置(整体 volatile:任一字段变更都不重挂)。 */
85
+ function backendSchema(spec) {
86
+ const dict = {
87
+ enabled: z.boolean().default(false),
88
+ executable: z.string().default(spec.executable),
89
+ }
90
+ if (spec.profile) dict.profile = z.string().default('')
91
+ dict.permissionMode = z.union(spec.permissionModes).default(spec.permissionMode)
92
+ dict.defaultModel = z.string().default('')
93
+ dict.models = z.array(ModelSchema).default([])
94
+ return z.object(dict).volatile()
95
+ }
96
+
97
+ /**
98
+ * 本插件行的正式配置 schema。
99
+ *
100
+ * `exitGuard`、`subagents.{eng,global}`、`subagents.{codex,claude}` 标 volatile
101
+ * (即时生效、不重挂);`trustedHosts`、`declareOwnsHost` 是普通部署标志
102
+ * (改动重挂本行后生效)。
103
+ */
104
+ export const Config = z.object({
105
+ /** 退出守卫总开关(默认关闭,显式开启才拦回合结束)。 */
106
+ exitGuard: z.boolean().default(false).volatile(),
107
+ /** 子代理 CLI 后端的接入范围与每后端配置。 */
108
+ subagents: z.object({
109
+ /** 工程模式预设(`eng`/`eng-noask`)可见这些后端。 */
110
+ eng: z.boolean().default(true).volatile(),
111
+ /** 所有预设可见这些后端。 */
112
+ global: z.boolean().default(false).volatile(),
113
+ codex: backendSchema(BACKENDS.codex),
114
+ claude: backendSchema(BACKENDS.claude),
115
+ }),
116
+ /** 允许调用本包 HTTP 路由的反向代理权威(host 或 host:port);默认只信 loopback。 */
117
+ trustedHosts: z.array(z.string()).default([]),
118
+ /** 注入 `__DSH_TRANSPORT__.ownsHost` 让官方 client 把反代页面当 loopback(默认关闭)。 */
119
+ declareOwnsHost: z.boolean().default(false),
120
+ })
121
+
122
+ // ── 规整(volatile 引用 → 普通 JSON)────────────────────────────────────────
123
+
124
+ /** 是否是 cosmokit volatile 引用(跨库副本按共享符号识别)。 */
125
+ function isVolatileRef(value) {
126
+ return typeof value === 'object' && value !== null
127
+ && typeof value.get === 'function' && VOLATILE_WRITE in value
128
+ }
129
+
130
+ /** 递归展开 volatile 引用,返回与输入同形的普通 JSON 数据。 */
131
+ function plain(value) {
132
+ if (isVolatileRef(value)) return plain(value.get())
133
+ if (Array.isArray(value)) return value.map((entry) => plain(entry))
134
+ if (value !== null && typeof value === 'object') {
135
+ const result = {}
136
+ for (const [key, entry] of Object.entries(value)) result[key] = plain(entry)
137
+ return result
138
+ }
139
+ return value
140
+ }
141
+
142
+ /** 是否是普通数据对象(数组、null、类实例都不算)。 */
143
+ function isRecord(value) {
144
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
145
+ }
146
+
147
+ /** 布尔读取:非布尔一律回退默认。 */
148
+ function booleanOf(value, fallback) {
149
+ return typeof value === 'boolean' ? value : fallback
150
+ }
151
+
152
+ /** 字符串读取:非字符串或空串回退默认;否则去首尾空白。 */
153
+ function stringOf(value, fallback) {
154
+ if (typeof value !== 'string') return fallback
155
+ const trimmed = value.trim()
156
+ return trimmed === '' ? fallback : trimmed
157
+ }
158
+
159
+ /** 枚举读取:不在允许集合内一律回退默认。 */
160
+ function oneOf(value, allowed, fallback) {
161
+ return typeof value === 'string' && allowed.includes(value) ? value : fallback
162
+ }
163
+
164
+ /** 字符串数组读取:丢弃非字符串/空串,去重并保持顺序。 */
165
+ function stringList(value) {
166
+ if (!Array.isArray(value)) return []
167
+ const result = []
168
+ for (const entry of value) {
169
+ if (typeof entry !== 'string') continue
170
+ const trimmed = entry.trim()
171
+ if (trimmed === '' || result.includes(trimmed)) continue
172
+ result.push(trimmed)
173
+ }
174
+ return result
175
+ }
176
+
177
+ /**
178
+ * 模型列表读取:丢弃无 id 的条目与重复 id,`efforts` 去重,`defaultEffort`
179
+ * 只在属于该模型 `efforts` 时保留(否则清空——消费方会拒绝不一致的组合)。
180
+ */
181
+ function modelsOf(value) {
182
+ if (!Array.isArray(value)) return []
183
+ const result = []
184
+ const seen = new Set()
185
+ for (const entry of value) {
186
+ if (!isRecord(entry)) continue
187
+ const id = stringOf(entry.id, '')
188
+ if (id === '' || seen.has(id)) continue
189
+ seen.add(id)
190
+ const efforts = stringList(entry.efforts)
191
+ const defaultEffort = stringOf(entry.defaultEffort, '')
192
+ result.push({
193
+ id,
194
+ efforts,
195
+ defaultEffort: efforts.includes(defaultEffort) ? defaultEffort : '',
196
+ })
197
+ }
198
+ return result
199
+ }
200
+
201
+ /** 一个后端对象读取。 */
202
+ function backendOf(value, spec) {
203
+ const source = isRecord(value) ? value : {}
204
+ const backend = {
205
+ enabled: booleanOf(source.enabled, false),
206
+ executable: stringOf(source.executable, spec.executable),
207
+ }
208
+ if (spec.profile) backend.profile = stringOf(source.profile, '')
209
+ backend.permissionMode = oneOf(source.permissionMode, spec.permissionModes, spec.permissionMode)
210
+ backend.defaultModel = stringOf(source.defaultModel, '')
211
+ backend.models = modelsOf(source.models)
212
+ return backend
213
+ }
214
+
215
+ /**
216
+ * 把任意配置来源规整成普通 JSON(形状 EXACT,非法值回退默认)。
217
+ *
218
+ * 输入可以是 `entry.fiber.config`(volatile 字段是引用对象)、旧 JSON 文档、
219
+ * 残缺对象或 `undefined`;返回值总是全新的、可安全改写的普通对象。
220
+ * @param config - 任意配置值。
221
+ * @returns `{ exitGuard, subagents: { eng, global, codex, claude }, trustedHosts, declareOwnsHost }`。
222
+ */
223
+ export function normalizeConfig(config) {
224
+ const projected = plain(config)
225
+ const source = isRecord(projected) ? projected : {}
226
+ const subagents = isRecord(source.subagents) ? source.subagents : {}
227
+ return {
228
+ exitGuard: booleanOf(source.exitGuard, false),
229
+ subagents: {
230
+ eng: booleanOf(subagents.eng, true),
231
+ global: booleanOf(subagents.global, false),
232
+ codex: backendOf(subagents.codex, BACKENDS.codex),
233
+ claude: backendOf(subagents.claude, BACKENDS.claude),
234
+ },
235
+ trustedHosts: stringList(source.trustedHosts),
236
+ declareOwnsHost: booleanOf(source.declareOwnsHost, false),
237
+ }
238
+ }
239
+
240
+ /** 默认配置形状(`normalizeConfig(undefined)` 的结果)。 */
241
+ export const DEFAULT_CONFIG = normalizeConfig(undefined)
242
+
243
+ // ── 活配置读取 ──────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * 读取一个可选服务:`ctx.get(name)` 不要求 inject 且不会抛错(预设本地插件
247
+ * 用它读宿主服务),`ctx[name]` 只在没注入时会抛——两层都包住。
248
+ * @param ctx - 任意插件上下文。
249
+ * @param name - 服务名。
250
+ * @returns 服务实例,或 undefined。
251
+ */
252
+ function readService(ctx, name) {
253
+ if (ctx === undefined || ctx === null) return undefined
254
+ try {
255
+ if (typeof ctx.get === 'function') {
256
+ const service = ctx.get(name)
257
+ if (service !== undefined && service !== null) return service
258
+ }
259
+ } catch {
260
+ // 服务不可见(未注入/隔离/未激活)→ 退回属性读取。
261
+ }
262
+ try {
263
+ return ctx[name]
264
+ } catch {
265
+ return undefined
266
+ }
267
+ }
268
+
269
+ /**
270
+ * 经 configEditor 找到本行的活 entry(按行 id,其次按新包名,最后按旧包名)。
271
+ * @param ctx - 任意插件上下文。
272
+ * @returns Loader entry,或 undefined。
273
+ */
274
+ export function liveConfigEntry(ctx) {
275
+ const editor = readService(ctx, 'configEditor')
276
+ if (editor === undefined || typeof editor.entries !== 'function') return undefined
277
+ try {
278
+ const entries = editor.entries()
279
+ if (!Array.isArray(entries)) return undefined
280
+ return entries.find((row) => row?.options?.id === ENTRY_ID)
281
+ ?? entries.find((row) => row?.options?.name === PACKAGE_NAME)
282
+ ?? entries.find((row) => row?.options?.name === LEGACY_PACKAGE_NAME)
283
+ } catch {
284
+ return undefined
285
+ }
286
+ }
287
+
288
+ /**
289
+ * 读取本行的**活**配置并规整(volatile 引用按当前值展开)。
290
+ *
291
+ * 配置缺失、configEditor 不可见或 entry 已被替换时返回 `fallback` 的规整值
292
+ * (默认形状),绝不抛错——调用方是回合结束/子代理路径,不能因配置故障崩。
293
+ * @param ctx - 插件上下文(预设守卫可直接用 `ctx.get('configEditor')` 读,无需 inject)。
294
+ * @param fallback - 找不到活 entry 时使用的配置来源(通常是 apply 收到的 config)。
295
+ * @returns 规整后的普通 JSON 配置。
296
+ */
297
+ export function currentConfig(ctx, fallback) {
298
+ const entry = liveConfigEntry(ctx)
299
+ if (entry === undefined) return normalizeConfig(fallback)
300
+ return normalizeConfig(entry.fiber?.config ?? fallback)
301
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * DSH home 目录解析。
3
+ *
4
+ * 当前唯一消费方是 `src/settings.js`:它据此定位 `<dshHome>/eng.json`(旧名
5
+ * `atlas.json`)做一次**只读**的旧配置导入。预设本地插件不再各持一份同构副本
6
+ * ——`presets/<id>/plugins/lib/atlas-config.js` 已删除,预设插件改为直接读活配置
7
+ * (`src/config.js` 的 `currentConfig`):设置本身早已是正式 Config,落在活动
8
+ * profile 的 `cordis.patch.yml` 里,本文件只服务于那次遗留文件迁移。
9
+ *
10
+ * 解析规则:DSH_HOME 环境变量优先(`~` 展开、相对路径基于进程 CWD),缺省
11
+ * `<home>/.dsh`。只用 node 内置模块,可从任意位置加载。
12
+ */
13
+
14
+ import { homedir } from 'node:os'
15
+ import { isAbsolute, join } from 'node:path'
16
+
17
+ /**
18
+ * 解析 DSH home 目录。
19
+ * @param env - 读取 DSH_HOME 的进程环境(测试注入点)。
20
+ * @param home - 平台 home 目录回退(测试注入点)。
21
+ * @returns 绝对 DSH home 路径。
22
+ */
23
+ export function resolveDshHome(env = process.env, home = homedir()) {
24
+ const raw = env.DSH_HOME
25
+ if (raw !== undefined && raw.trim() !== '') {
26
+ const trimmed = raw.trim()
27
+ const expanded = trimmed === '~' ? home
28
+ : trimmed.startsWith('~/') || trimmed.startsWith('~\\') ? join(home, trimmed.slice(2))
29
+ : trimmed
30
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
31
+ }
32
+ return join(home, '.dsh')
33
+ }
34
+
35
+ /** 从当前环境解析 DSH home 目录。 */
36
+ export function dshHome() {
37
+ return resolveDshHome()
38
+ }
package/src/index.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Apollo's single host row registers bundled skills, profile-backed settings,
3
+ * and the CLI adapters that extend existing subagent tools. Presets are still
4
+ * delivered by bundle patches. Browser code remains a no-build closure factory.
5
+ */
6
+
7
+ import { readFile, readdir } from 'node:fs/promises'
8
+ import { dirname, join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { mountSettings } from './settings.js'
11
+ import { mountSubagents } from './subagents.js'
12
+ export { Config } from './config.js'
13
+
14
+ const PROVIDER_NAME = 'dsh-apollo'
15
+ /** Rank of bundled contributions in the local-provider ladder (user roots override). */
16
+ const BUNDLED_RANK = 600
17
+ const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url))
18
+ const SKILLS_DIR = join(PACKAGE_ROOT, 'skills')
19
+ const SKILL_NAME = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/
20
+
21
+ /** Cordis plugin name (the profile row id is `apollo-skills`). */
22
+ export const name = 'apollo-skills'
23
+ /** Service required by the bundled skill provider. */
24
+ export const inject = ['skills']
25
+
26
+ /** Register the packaged skills provider and mount the panel. */
27
+ export function apply(ctx, config) {
28
+ ctx.skills.registerProvider(() => createProvider(ctx))
29
+ if (typeof ctx.inject === 'function') {
30
+ mountSettings(ctx, config)
31
+ mountSubagents(ctx, config)
32
+ }
33
+ }
34
+
35
+ function createProvider(ctx) {
36
+ let discovery
37
+ const observed = () => (discovery ??= discover(ctx))
38
+ return {
39
+ name: PROVIDER_NAME,
40
+ async list() {
41
+ return (await observed()).candidates
42
+ },
43
+ async get(candidate) {
44
+ const entry = (await observed()).byLocator.get(candidate.locator)
45
+ if (entry === undefined) return undefined
46
+ const parsed = await parseSkillFile(entry.file)
47
+ if (parsed === undefined || parsed.name !== entry.name) return undefined
48
+ return {
49
+ ...entry.summary,
50
+ content: parsed.body,
51
+ path: entry.file,
52
+ metadata: parsed.metadata,
53
+ }
54
+ },
55
+ }
56
+ }
57
+
58
+ /** Scan the packaged skills tree once; malformed entries warn and skip. */
59
+ async function discover(ctx) {
60
+ const candidates = []
61
+ const byLocator = new Map()
62
+ let entries
63
+ try {
64
+ entries = await readdir(SKILLS_DIR, { withFileTypes: true })
65
+ } catch (error) {
66
+ ctx.logger.warn(`dsh-apollo: skills directory unreadable: ${error.message}`)
67
+ return { candidates, byLocator }
68
+ }
69
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
70
+ if (!entry.isDirectory()) continue
71
+ const file = join(SKILLS_DIR, entry.name, 'SKILL.md')
72
+ const parsed = await parseSkillFile(file)
73
+ if (parsed === undefined) {
74
+ ctx.logger.warn(`dsh-apollo: skill file ${file} ignored: missing or invalid frontmatter`)
75
+ continue
76
+ }
77
+ if (!SKILL_NAME.test(parsed.name)) {
78
+ ctx.logger.warn(`dsh-apollo: skill file ${file} ignored: name "${parsed.name}" is not kebab-case`)
79
+ continue
80
+ }
81
+ const summary = {
82
+ name: parsed.name,
83
+ description: parsed.description,
84
+ ...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
85
+ invocation: {
86
+ modelInvocable: parsed.disableModelInvocation !== true,
87
+ userInvocable: parsed.userInvocable !== false,
88
+ },
89
+ provider: PROVIDER_NAME,
90
+ source: 'bundled',
91
+ resourceBase: { kind: 'directory', path: dirname(file) },
92
+ }
93
+ const candidate = {
94
+ ...summary,
95
+ rank: BUNDLED_RANK,
96
+ locator: file,
97
+ path: file,
98
+ metadata: parsed.metadata,
99
+ }
100
+ candidates.push(candidate)
101
+ byLocator.set(file, { file, name: parsed.name, summary })
102
+ }
103
+ return { candidates, byLocator }
104
+ }
105
+
106
+ /**
107
+ * Parse one SKILL.md: `---`-fenced frontmatter with single-line `key: value`
108
+ * pairs, then the raw body. Values may be double- or single-quoted; unknown
109
+ * keys are preserved in `metadata`. Returns undefined when the file cannot
110
+ * be read or the frontmatter lacks `name` and `description`.
111
+ */
112
+ async function parseSkillFile(file) {
113
+ let raw
114
+ try {
115
+ raw = await readFile(file, 'utf8')
116
+ } catch {
117
+ return undefined
118
+ }
119
+ const lines = raw.split('\n')
120
+ if (lines[0] === undefined || lines[0].replace(/\r$/, '') !== '---') return undefined
121
+ const fields = new Map()
122
+ let cursor = 1
123
+ for (; cursor < lines.length; cursor += 1) {
124
+ const line = lines[cursor].replace(/\r$/, '')
125
+ if (line === '---') break
126
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
127
+ const match = /^([A-Za-z0-9_-]+):[ \t]*(.*)$/.exec(line)
128
+ if (match === null) return undefined
129
+ fields.set(match[1], unquote(match[2].trim()))
130
+ }
131
+ if (cursor >= lines.length) return undefined
132
+ const name = fields.get('name')
133
+ const description = fields.get('description')
134
+ if (typeof name !== 'string' || name === '' || typeof description !== 'string' || description === '') {
135
+ return undefined
136
+ }
137
+ fields.delete('name')
138
+ fields.delete('description')
139
+ const whenToUse = fields.get('whenToUse')
140
+ const disableModelInvocation = booleanField(fields, 'disable-model-invocation')
141
+ if (disableModelInvocation === null) return undefined
142
+ const userInvocable = booleanField(fields, 'user-invocable')
143
+ if (userInvocable === null) return undefined
144
+ fields.delete('whenToUse')
145
+ fields.delete('disable-model-invocation')
146
+ fields.delete('user-invocable')
147
+ return {
148
+ name,
149
+ description,
150
+ whenToUse,
151
+ disableModelInvocation,
152
+ userInvocable,
153
+ metadata: Object.fromEntries(fields),
154
+ body: lines.slice(cursor + 1).join('\n'),
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Read one frontmatter boolean. Returns undefined for an absent field,
160
+ * true/false for a valid value, and null for a present-but-invalid value,
161
+ * which invalidates the whole file.
162
+ */
163
+ function booleanField(fields, key) {
164
+ const value = fields.get(key)
165
+ if (value === undefined || value === '') return undefined
166
+ if (value === 'true') return true
167
+ if (value === 'false') return false
168
+ return null
169
+ }
170
+
171
+ /** Strip one pair of matching surrounding quotes. */
172
+ function unquote(value) {
173
+ if (value.length >= 2) {
174
+ const head = value[0]
175
+ const tail = value[value.length - 1]
176
+ if ((head === '"' && tail === '"') || (head === "'" && tail === "'")) {
177
+ return value.slice(1, -1)
178
+ }
179
+ }
180
+ return value
181
+ }