@mhfire/dsh-im-bridge 0.2.0 → 0.4.2

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,1015 @@
1
+ /**
2
+ * Locate the bundled wecom-cli binary, keep it on PATH, probe auth, and load
3
+ * official wecomcli-* SKILL.md files from a managed directory.
4
+ */
5
+
6
+ import { execFile } from 'node:child_process'
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ chmodSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
16
+ import { createRequire } from 'node:module'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
19
+ import { promisify } from 'node:util'
20
+ import { unzipSync } from 'fflate'
21
+ import { parse as parseYaml } from 'yaml'
22
+
23
+ const execFileAsync = promisify(execFile)
24
+
25
+ /** Kebab-case skill names accepted by DSH (`wecomcli-shared`). */
26
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
27
+
28
+ /** Workspace-relative DSH native skills directory (`.dsh/skills`). Used only to detect leftover wecomcli-*. */
29
+ export const DEFAULT_WORKSPACE_SKILLS_DIR = join('.dsh', 'skills')
30
+
31
+ /** Workspace-relative Cursor/agents skills directory (`.agents/skills`). Used only to detect leftover wecomcli-*. */
32
+ export const DEFAULT_WORKSPACE_AGENTS_SKILLS_DIR = join('.agents', 'skills')
33
+
34
+ /** Managed wecomcli-* root under `$DSH_HOME` (not `$DSH_HOME/skills`, which skill-filesystem still scans). */
35
+ export const DEFAULT_MANAGED_SKILLS_DIR = 'wecom-cli-skills'
36
+
37
+ /** Workspace-relative wecom-cli credential directory (writable under sandbox). */
38
+ export const DEFAULT_WORKSPACE_CONFIG_DIR = join('.dsh', 'wecom-cli')
39
+
40
+ /** Official CLI env that overrides `~/.config/wecom`. */
41
+ export const WECOM_CLI_CONFIG_DIR_ENV = 'WECOM_CLI_CONFIG_DIR'
42
+
43
+ /** Directory that holds the PATH deny shim. */
44
+ export const SHIM_DIR_NAME = 'wecom-cli-bin'
45
+
46
+ /** Model-facing name of the gated office tool. */
47
+ export const WECOM_CLI_TOOL_NAME = 'wecom_cli'
48
+
49
+ /** Wall-clock limit for one gated office command. */
50
+ export const WECOM_CLI_TOOL_TIMEOUT_MS = 120_000
51
+
52
+ /** Byte ceiling applied to each of stdout and stderr before the model sees them. */
53
+ export const WECOM_CLI_TOOL_MAX_OUTPUT_BYTES = 60_000
54
+
55
+ /**
56
+ * What the PATH shim prints before exiting 1. ASCII only: a `.cmd` echoes the
57
+ * file's bytes, and a console under a non-UTF-8 code page would garble Chinese.
58
+ */
59
+ export const WECOM_CLI_SHIM_DENY_MESSAGE =
60
+ 'wecom-cli is disabled in this window. Office 1:1 agents must call the wecom_cli tool with an argv array; group chats have no office access.'
61
+
62
+ /** Timeout for `wecom-cli auth show --status`. */
63
+ export const AUTH_PROBE_TIMEOUT_MS = 10_000
64
+
65
+ /** Timeout for `wecom-cli auth init --bot-id/--secret` (CLI contacts WeCom). */
66
+ export const AUTH_INIT_TIMEOUT_MS = 30_000
67
+
68
+ /** Host TTY fallback command; the credential directory is deployment-specific, so use {@link authInitHint}. */
69
+ export const AUTH_INIT_COMMAND = 'npx --yes @wecom/cli auth init --manual'
70
+
71
+ /**
72
+ * Host-terminal fallback that writes into the credential directory this plugin reads.
73
+ * The plugin injects `WECOM_CLI_CONFIG_DIR` per spawn, so a bare terminal would
74
+ * otherwise authorize `~/.config/wecom`, which nothing here reads.
75
+ * @param configDir - absolute credential directory.
76
+ * @param platform - target platform; selects pwsh or POSIX env syntax.
77
+ * @returns a one-line command to run on the host.
78
+ */
79
+ export function authInitHint(
80
+ configDir: string,
81
+ platform: NodeJS.Platform = process.platform,
82
+ ): string {
83
+ return platform === 'win32'
84
+ ? `$env:${WECOM_CLI_CONFIG_DIR_ENV}='${configDir}'; ${AUTH_INIT_COMMAND}`
85
+ : `${WECOM_CLI_CONFIG_DIR_ENV}='${configDir}' ${AUTH_INIT_COMMAND}`
86
+ }
87
+
88
+ /** Logged when plugin config has no Bot ID or Secret. Must never include the secret value. */
89
+ export const AUTH_INIT_MISSING_MESSAGE = '缺少 botId 或密钥,无法写入 wecom-cli 凭据。'
90
+
91
+ /**
92
+ * Logged when automatic seeding exits non-zero. Must never include botId or the secret value.
93
+ * @param configDir - absolute credential directory, for the manual fallback.
94
+ * @returns the operator-facing failure line.
95
+ */
96
+ export function authInitFailedMessage(configDir: string): string {
97
+ return `wecom-cli 未能用已有 Bot ID 完成授权。请重启 dsh,或在 host 终端执行 ${authInitHint(configDir)}(输入同一套密钥,不要全局安装)。`
98
+ }
99
+
100
+ /** Logged when wecomCli is on but wecomCli.allowFrom is empty. */
101
+ export const ALLOW_FROM_REQUIRED_MESSAGE =
102
+ 'wecomCli 已开启但 wecomCli.allowFrom 为空;已跳过 wecom-cli 的 PATH 与授权检查。聊天仍由根级 allowFrom 控制(空 = 所有人)。请把办公 userid 配进 wecomCli.allowFrom。工作区 .dsh/skills 或 .agents/skills 里残留的 wecomcli-* 仍会被该 cwd 下所有 Agent 发现。'
103
+
104
+ /** Logged when the Agent context has no skills.register. */
105
+ export const SKILLS_SERVICE_MISSING_MESSAGE =
106
+ '当前 Agent 没有 skills 服务,无法注册 wecomcli-*。'
107
+
108
+ /** Logged when the Agent context has no tools.register. */
109
+ export const TOOLS_SERVICE_MISSING_MESSAGE =
110
+ `当前 Agent 没有 tools 服务,无法注册 ${WECOM_CLI_TOOL_NAME},办公命令不可用。`
111
+
112
+ /** Logged when workspace scan roots still contain wecomcli-*. */
113
+ export const WORKSPACE_WECOMCLI_LEAK_MESSAGE =
114
+ '工作区 .dsh/skills 或 .agents/skills 仍有 wecomcli-*,同 cwd 的 GUI/群聊仍会发现。请挪到 $DSH_HOME/wecom-cli-skills 后删除工作区副本。'
115
+
116
+ /** Official GitHub zip of wecom-cli (skills live under `skills/wecomcli-*`). */
117
+ export const WECOM_CLI_SKILLS_ARCHIVE_URL =
118
+ 'https://github.com/WeComTeam/wecom-cli/archive/refs/heads/main.zip'
119
+
120
+ /** Connection channel for the Settings install button. */
121
+ export const IM_BRIDGE_RPC_CHANNEL = '/im-bridge'
122
+
123
+ /** Unary endpoint that downloads and extracts official wecomcli-* skills. */
124
+ export const INSTALL_SKILLS_ENDPOINT = 'wecomcli.installSkills'
125
+
126
+ /** Logged when the Host has no Connection (no Settings install button). */
127
+ export const SKILLS_RPC_UNAVAILABLE_MESSAGE =
128
+ '当前 Host 没有 Connection,Settings 安装按钮不可用。'
129
+
130
+ /** Zip entry under the GitHub archive: `wecom-cli-<ref>/skills/wecomcli-<name>/...`. */
131
+ const ARCHIVE_SKILL_FILE =
132
+ /^wecom-cli-[^/]+\/skills\/(wecomcli-[a-z0-9]+(?:-[a-z0-9]+)*)\/(.+)$/
133
+
134
+ /**
135
+ * GitHub zips list directories as empty entries. Writing those as files makes
136
+ * a later mkdir of the same path fail with EEXIST.
137
+ * @param name - zip path with `/` separators.
138
+ * @param data - entry bytes.
139
+ * @returns true when this entry must not be written as a file.
140
+ */
141
+ function isZipDirectoryEntry(name: string, data: Uint8Array): boolean {
142
+ if (name.endsWith('/')) return true
143
+ if (data.byteLength > 0) return false
144
+ const last = name.split('/').filter(Boolean).at(-1) ?? ''
145
+ return !last.includes('.')
146
+ }
147
+
148
+ /** Channel rules for every WeCom Agent: no GUI confirm, so `ask_user_question` hangs. */
149
+ export const WECOM_CHANNEL_PROMPT =
150
+ '本通道是企业微信,没有确认框。禁止调用 ask_user_question,它会挂到任务超时;有问题写在回复里问。'
151
+
152
+ /** Extra system-prompt rules for office 1:1 (gated `wecom_cli` tool). */
153
+ export const WECOM_CLI_PROMPT = [
154
+ WECOM_CHANNEL_PROMPT,
155
+ `办公只用 ${WECOM_CLI_TOOL_NAME}:argv 为 wecom-cli 之后的参数,例如 ["message","aibot","sessions","list"]。禁止用 pwsh/bash/npx/npm 再跑 wecom-cli。`,
156
+ '发信、取消会议、删待办、覆盖文档:先 --dry-run,回复里说明,等用户下一条确认再执行。',
157
+ `禁止任何 auth init 与扫码(会新建机器人)。凭证由插件维护。报 853004 时用 ${WECOM_CLI_TOOL_NAME} 重试该命令;仍失败则让用户重启 dsh。`,
158
+ ].join('\n')
159
+
160
+ /** Prompt for group chats, non-office 1:1, and WeCom agents with wecomCli off. */
161
+ export const WECOM_CLI_NO_OFFICE_PROMPT = [
162
+ WECOM_CHANNEL_PROMPT,
163
+ `本通道没有企微办公权限:没有 ${WECOM_CLI_TOOL_NAME},禁止 wecom-cli / npx @wecom/cli / wecomcli-*。只回答诊断与当前工作区任务。`,
164
+ ].join('\n')
165
+
166
+ /** Prepended to every official skill body: its `wecom-cli ...` lines are not runnable here. */
167
+ export const WECOM_CLI_SKILL_PREFIX = [
168
+ `执行方式:本机没有可直接运行的 wecom-cli 命令。下文每条 \`wecom-cli ...\` 都改为调用 ${WECOM_CLI_TOOL_NAME} 工具,argv 传命令名之后的参数。`,
169
+ `例如 \`wecom-cli message aibot send --chat-id X\` → ${WECOM_CLI_TOOL_NAME}({"argv":["message","aibot","send","--chat-id","X"]})。`,
170
+ '禁止用 pwsh/bash/npx 运行 wecom-cli。',
171
+ ].join('\n')
172
+
173
+ /** One parsed official wecom-cli skill. */
174
+ export interface WecomSkill {
175
+ /** Frontmatter `name` (kebab-case). */
176
+ name: string
177
+ /** Frontmatter `description`. */
178
+ description: string
179
+ /** Markdown body after the frontmatter. */
180
+ content: string
181
+ /** Skill directory; used as DSH `resourceBase.path`. */
182
+ directory: string
183
+ }
184
+
185
+ /** Duck-typed payload for `ctx.skills.register()` (no `@deepseek-ai/dsh-skill` import). */
186
+ export interface RuntimeSkillRegistration {
187
+ /** Kebab-case skill name. */
188
+ name: string
189
+ /** Catalog description. */
190
+ description: string
191
+ /** Runtime provider bucket; must not land in skill-filesystem roots. */
192
+ source: 'runtime'
193
+ /** Instruction body. */
194
+ content: string
195
+ /** Directory that holds SKILL.md and relative resources. */
196
+ resourceBase: { kind: 'directory'; path: string }
197
+ }
198
+
199
+ /** Minimal Cordis face used to look up an optional Agent-scoped service. */
200
+ export interface AgentServiceHost {
201
+ /** Service lookup; a missing service is a no-op with a warning. */
202
+ get(name: string): unknown
203
+ }
204
+
205
+ /** One completed gated CLI run; matches the tool's `output.schema` exactly. */
206
+ export interface WecomCliRun {
207
+ /** Captured standard output, clipped to {@link WECOM_CLI_TOOL_MAX_OUTPUT_BYTES}. */
208
+ stdout: string
209
+ /** Captured standard error, clipped the same way; also carries spawn failures. */
210
+ stderr: string
211
+ /** Process exit code; a spawn failure or timeout reports 1. */
212
+ exitCode: number
213
+ }
214
+
215
+ /** The part of a DSH tool execution context this tool uses. */
216
+ export interface RuntimeToolExec {
217
+ /** Caller-owned cancellation for this call. */
218
+ signal: AbortSignal
219
+ }
220
+
221
+ /** Duck-typed `ctx.tools.register()` payload (no `@deepseek-ai/dsh-tools` import). */
222
+ export interface RuntimeToolRegistration {
223
+ /** Model-visible tool name. */
224
+ name: string
225
+ /** Model-visible description. */
226
+ description: string
227
+ /** JSON Schema for the model's arguments. */
228
+ parameters: Record<string, unknown>
229
+ /** Mandatory canonical output declaration; the registry validates every returned value against `schema`. */
230
+ output: {
231
+ /** JSON Schema of the value `execute` returns. */
232
+ schema: Record<string, unknown>
233
+ /** Pure projection from the canonical value to model-facing content. */
234
+ render(args: unknown, value: unknown): Array<{ type: 'text'; text: string }>
235
+ }
236
+ /** Cooperative wall-clock budget enforced by the host's timeout policy. */
237
+ timeoutMs?: number
238
+ /** Run one accepted call. */
239
+ execute(args: unknown, exec: RuntimeToolExec): Promise<WecomCliRun>
240
+ /** Pure pending-state card, derived from `args` alone. */
241
+ presentCall(args: unknown): { card: 'terminal'; title: string }
242
+ }
243
+
244
+ /** Result of installing the PATH deny shim. */
245
+ export interface EnsureOnPathResult {
246
+ /** Directory prepended to PATH; holds the deny shim. */
247
+ shimDir: string
248
+ /** True when another wecom-cli executable was already on PATH and is now shadowed. */
249
+ shadowed: boolean
250
+ }
251
+
252
+ /** `wecom-cli auth show --status` outcome. */
253
+ export type AuthStatus = 'authorized' | 'unauthorized' | 'error'
254
+
255
+ /** Result of writing official wecomcli-* into the managed skills directory. */
256
+ export interface InstallWecomSkillsResult {
257
+ /** Absolute destination directory. */
258
+ dest: string
259
+ /** How many wecomcli-* SKILL.md trees loaded after extract. */
260
+ count: number
261
+ }
262
+
263
+ /** Failed download or extract; message is safe to show in Settings. */
264
+ export class WecomSkillsInstallError extends Error {
265
+ /**
266
+ * @param message - short reason with no secrets.
267
+ */
268
+ constructor(message: string) {
269
+ super(message)
270
+ this.name = 'WecomSkillsInstallError'
271
+ }
272
+ }
273
+
274
+ /**
275
+ * DSH home: `$DSH_HOME`, else `~/.dsh`.
276
+ * @param env - environment to read; defaults to `process.env`.
277
+ * @returns the absolute home directory.
278
+ */
279
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env): string {
280
+ const fromEnv = env.DSH_HOME?.trim()
281
+ return fromEnv !== undefined && fromEnv !== '' ? fromEnv : join(homedir(), '.dsh')
282
+ }
283
+
284
+ /**
285
+ * Skills directory: configured path, else `$DSH_HOME/wecom-cli-skills`.
286
+ * Relative `configured` values resolve against `workspace`.
287
+ * @param configured - `wecomCli.skillsDir`; empty means the managed home default.
288
+ * @param workspace - Agent cwd / plugin `workspace`.
289
+ * @param env - environment for `$DSH_HOME`; defaults to `process.env`.
290
+ * @returns an absolute directory path.
291
+ */
292
+ export function resolveSkillsDir(
293
+ configured: string,
294
+ workspace: string,
295
+ env: NodeJS.ProcessEnv = process.env,
296
+ ): string {
297
+ const trimmed = configured.trim()
298
+ if (trimmed === '') return join(resolveDshHome(env), DEFAULT_MANAGED_SKILLS_DIR)
299
+ return isAbsolute(trimmed) ? trimmed : resolve(workspace, trimmed)
300
+ }
301
+
302
+ /**
303
+ * wecom-cli credential directory: configured path, else `<workspace>/.dsh/wecom-cli`.
304
+ * Relative `configured` values resolve against `workspace`.
305
+ * @param configured - `wecomCli.configDir`; empty means the workspace default.
306
+ * @param workspace - Agent cwd / plugin `workspace`.
307
+ * @returns an absolute directory path.
308
+ */
309
+ export function resolveConfigDir(configured: string, workspace: string): string {
310
+ const trimmed = configured.trim()
311
+ if (trimmed === '') return join(workspace, DEFAULT_WORKSPACE_CONFIG_DIR)
312
+ return isAbsolute(trimmed) ? trimmed : resolve(workspace, trimmed)
313
+ }
314
+
315
+ /**
316
+ * Create the credential directory. Deliberately does not touch `process.env`:
317
+ * an exported `WECOM_CLI_CONFIG_DIR` would hand the authorized identity to every
318
+ * child process, including a group chat's shell reaching a CLI copy some other way.
319
+ * @param dir - absolute credential directory.
320
+ * @returns `dir`.
321
+ */
322
+ export function ensureConfigDir(dir: string): string {
323
+ mkdirSync(dir, { recursive: true })
324
+ return dir
325
+ }
326
+
327
+ /**
328
+ * Environment for one CLI spawn: `WECOM_CLI_CONFIG_DIR` reaches the CLI only here,
329
+ * never `~/.config/wecom` and never the ambient process environment.
330
+ * @param configDir - absolute credential directory.
331
+ * @param base - environment to extend; defaults to `process.env`.
332
+ * @returns a new environment object; `base` is not mutated.
333
+ */
334
+ export function wecomCliEnv(
335
+ configDir: string,
336
+ base: NodeJS.ProcessEnv = process.env,
337
+ ): NodeJS.ProcessEnv {
338
+ return { ...base, [WECOM_CLI_CONFIG_DIR_ENV]: configDir }
339
+ }
340
+
341
+ /**
342
+ * How to land official skills in `dir`. The skills CLI has no `--dir` and `-g`
343
+ * leaks into `~/.agents/skills`.
344
+ * @param dir - destination skills directory.
345
+ * @returns a one-line install hint.
346
+ */
347
+ export function skillsInstallHint(dir: string): string {
348
+ return `在 Settings → 插件配置 → 企业微信桥接 点「安装官方 skills」,或把官方仓库 skills/wecomcli-* 拷到 "${dir}"(不要 npx skills add -g;CLI 没有 --dir)`
349
+ }
350
+
351
+ /**
352
+ * Download the official wecom-cli GitHub zip.
353
+ * @param fetchImpl - HTTP client; defaults to global `fetch`.
354
+ * @param signal - optional cancellation.
355
+ * @returns zip bytes.
356
+ */
357
+ export async function fetchWecomSkillsZip(
358
+ fetchImpl: typeof fetch = fetch,
359
+ signal?: AbortSignal,
360
+ ): Promise<Uint8Array> {
361
+ let response: Response
362
+ try {
363
+ response = await fetchImpl(WECOM_CLI_SKILLS_ARCHIVE_URL, { signal, redirect: 'follow' })
364
+ } catch {
365
+ throw new WecomSkillsInstallError('下载官方 skills 失败(网络)')
366
+ }
367
+ if (!response.ok) {
368
+ throw new WecomSkillsInstallError(`下载官方 skills 失败(HTTP ${String(response.status)})`)
369
+ }
370
+ return new Uint8Array(await response.arrayBuffer())
371
+ }
372
+
373
+ /**
374
+ * Extract `skills/wecomcli-*` from a GitHub archive zip into `dest`.
375
+ * Rejects entries whose path contains `..`. Overwrites matching skill folders
376
+ * and leaves other children of `dest` in place.
377
+ * @param zipBytes - GitHub `archive/refs/heads/main.zip` body.
378
+ * @param dest - managed skills root (`resolveSkillsDir`).
379
+ * @returns destination and loaded wecomcli-* count.
380
+ */
381
+ export function extractWecomSkillsFromZip(zipBytes: Uint8Array, dest: string): InstallWecomSkillsResult {
382
+ if (zipBytes.byteLength === 0) {
383
+ throw new WecomSkillsInstallError('下载的 zip 为空')
384
+ }
385
+ let files: Record<string, Uint8Array>
386
+ try {
387
+ files = unzipSync(zipBytes)
388
+ } catch {
389
+ throw new WecomSkillsInstallError('无法解压官方 skills zip')
390
+ }
391
+ const writes: Array<{ skill: string; rel: string; data: Uint8Array }> = []
392
+ for (const [rawName, data] of Object.entries(files)) {
393
+ const name = rawName.replaceAll('\\', '/')
394
+ if (name.split('/').includes('..')) {
395
+ throw new WecomSkillsInstallError('zip 含非法路径')
396
+ }
397
+ if (isZipDirectoryEntry(name, data)) continue
398
+ const match = ARCHIVE_SKILL_FILE.exec(name)
399
+ if (match === null || match[1] === undefined || match[2] === undefined) continue
400
+ writes.push({ skill: match[1], rel: match[2], data })
401
+ }
402
+ if (writes.length === 0) {
403
+ throw new WecomSkillsInstallError('zip 中没有 wecomcli-* skills')
404
+ }
405
+ writes.sort((left, right) => left.rel.split('/').length - right.rel.split('/').length)
406
+ mkdirSync(dest, { recursive: true })
407
+ const destRoot = resolve(dest)
408
+ for (const skill of new Set(writes.map(entry => entry.skill))) {
409
+ const skillDir = join(destRoot, skill)
410
+ if (existsSync(skillDir)) rmSync(skillDir, { recursive: true, force: true })
411
+ }
412
+ for (const entry of writes) {
413
+ const target = resolve(destRoot, entry.skill, entry.rel)
414
+ const relToDest = relative(destRoot, target)
415
+ if (relToDest.startsWith('..') || isAbsolute(relToDest)) {
416
+ throw new WecomSkillsInstallError('zip 含非法路径')
417
+ }
418
+ mkdirSync(dirname(target), { recursive: true })
419
+ writeFileSync(target, entry.data)
420
+ }
421
+ const count = countWecomcliSkills(loadWecomSkills(destRoot))
422
+ if (count === 0) {
423
+ throw new WecomSkillsInstallError('解压后未读到 wecomcli-* SKILL.md')
424
+ }
425
+ return { dest: destRoot, count }
426
+ }
427
+
428
+ /**
429
+ * Install official wecomcli-* into `dest`. Tests pass `zip`; production fetches.
430
+ * @param dest - managed skills root.
431
+ * @param options - fixture zip, fetch override, or cancellation.
432
+ * @returns destination and loaded count.
433
+ */
434
+ export async function installOfficialWecomSkills(
435
+ dest: string,
436
+ options?: {
437
+ zip?: Uint8Array
438
+ fetch?: typeof fetch
439
+ signal?: AbortSignal
440
+ },
441
+ ): Promise<InstallWecomSkillsResult> {
442
+ const zip = options?.zip ?? await fetchWecomSkillsZip(options?.fetch, options?.signal)
443
+ return extractWecomSkillsFromZip(zip, dest)
444
+ }
445
+
446
+ /**
447
+ * Whether wecomCli may set up PATH, auth, and office prompts.
448
+ * Uses `wecomCli.allowFrom`, not the chat `allowFrom`.
449
+ * Workspace wecomcli-* leftovers are still discovered by skill-filesystem.
450
+ * @param enabled - `wecomCli.enabled`.
451
+ * @param officeFrom - office-sender userid list.
452
+ * @returns true only when both conditions hold.
453
+ */
454
+ export function shouldEnableWecomCli(enabled: boolean, officeFrom: readonly string[]): boolean {
455
+ return enabled && officeFrom.some(id => id.trim() !== '')
456
+ }
457
+
458
+ /**
459
+ * Whether this chat may receive runtime wecomcli-* registration.
460
+ * Groups never inject; 1:1 injects only for an office sender.
461
+ * @param kind - WeCom window kind; undefined is treated as no inject.
462
+ * @param office - whether the current sender is on `wecomCli.allowFrom`.
463
+ * @returns true only for office 1:1 chats.
464
+ */
465
+ export function shouldInjectWecomOfficeSkills(
466
+ kind: 'single' | 'group' | undefined,
467
+ office: boolean,
468
+ ): boolean {
469
+ return kind === 'single' && office
470
+ }
471
+
472
+ /**
473
+ * Whether `sender` is on the office list (trimmed exact match).
474
+ * @param officeFrom - `wecomCli.allowFrom`.
475
+ * @param sender - inbound userid.
476
+ * @returns true when the sender may receive the office prompt.
477
+ */
478
+ export function senderHasOfficeAccess(officeFrom: readonly string[], sender: string): boolean {
479
+ const id = sender.trim()
480
+ if (id === '') return false
481
+ return officeFrom.some(entry => entry.trim() === id)
482
+ }
483
+
484
+ /**
485
+ * Workspace skill-filesystem roots that would leak wecomcli-* to every cwd Agent.
486
+ * @param workspace - Agent cwd / plugin `workspace`.
487
+ * @returns `.dsh/skills` then `.agents/skills` under `workspace`.
488
+ */
489
+ export function workspaceWecomcliLeakRoots(workspace: string): readonly string[] {
490
+ return [
491
+ join(workspace, DEFAULT_WORKSPACE_SKILLS_DIR),
492
+ join(workspace, DEFAULT_WORKSPACE_AGENTS_SKILLS_DIR),
493
+ ]
494
+ }
495
+
496
+ /**
497
+ * Count `wecomcli-*` still sitting in workspace scan roots.
498
+ * @param workspace - Agent cwd / plugin `workspace`.
499
+ * @returns total parsed wecomcli-* across leak roots.
500
+ */
501
+ export function countWorkspaceWecomcliLeaks(workspace: string): number {
502
+ let total = 0
503
+ for (const root of workspaceWecomcliLeakRoots(workspace)) {
504
+ total += countWecomcliSkills(loadWecomSkills(root))
505
+ }
506
+ return total
507
+ }
508
+
509
+ /**
510
+ * Count official wecom-cli skills (`wecomcli-*`) in a loaded list.
511
+ * @param skills - parsed skills from a directory scan.
512
+ * @returns how many names start with `wecomcli-`.
513
+ */
514
+ export function countWecomcliSkills(skills: readonly WecomSkill[]): number {
515
+ return skills.filter(skill => skill.name.startsWith('wecomcli-')).length
516
+ }
517
+
518
+ /**
519
+ * Map a parsed wecomcli-* skill to a runtime registration. Other names are ignored.
520
+ * The body keeps the official `wecom-cli ...` command lines, so
521
+ * {@link WECOM_CLI_SKILL_PREFIX} leads and redirects them to the gated tool.
522
+ * @param skill - parsed SKILL.md.
523
+ * @returns the registration, or undefined when the name is not `wecomcli-*`.
524
+ */
525
+ export function toRuntimeSkillRegistration(skill: WecomSkill): RuntimeSkillRegistration | undefined {
526
+ if (!skill.name.startsWith('wecomcli-')) return undefined
527
+ return {
528
+ name: skill.name,
529
+ description: skill.description,
530
+ source: 'runtime',
531
+ content: `${WECOM_CLI_SKILL_PREFIX}\n\n${skill.content}`,
532
+ resourceBase: { kind: 'directory', path: skill.directory },
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Register wecomcli-* on this Agent's skills layer. Must be called on `agentCtx`, not the host.
538
+ * @param agentCtx - the Agent-scoped Cordis context.
539
+ * @param skills - parsed skills; non-wecomcli names are skipped.
540
+ * @returns how many skills were registered.
541
+ */
542
+ export function registerWecomOfficeSkills(
543
+ agentCtx: AgentServiceHost,
544
+ skills: readonly WecomSkill[],
545
+ ): number {
546
+ const registry = skillsRegisterOf(agentCtx)
547
+ if (registry === undefined) {
548
+ console.warn(`[im-bridge] ${SKILLS_SERVICE_MISSING_MESSAGE}`)
549
+ return 0
550
+ }
551
+ let count = 0
552
+ for (const skill of skills) {
553
+ const registration = toRuntimeSkillRegistration(skill)
554
+ if (registration === undefined) continue
555
+ registry.register(registration)
556
+ count += 1
557
+ }
558
+ return count
559
+ }
560
+
561
+ function skillsRegisterOf(agentCtx: AgentServiceHost): { register(skill: RuntimeSkillRegistration): () => void } | undefined {
562
+ const skills = agentCtx.get('skills')
563
+ if (skills === undefined || skills === null || typeof skills !== 'object') return undefined
564
+ const register = (skills as { register?: unknown }).register
565
+ if (typeof register !== 'function') return undefined
566
+ return skills as { register(skill: RuntimeSkillRegistration): () => void }
567
+ }
568
+
569
+ /**
570
+ * Resolve `@wecom/cli`'s `bin/wecom.js` from this package's node_modules.
571
+ * @returns the absolute launcher path, or undefined when the package is missing.
572
+ */
573
+ export function resolveWecomBin(): string | undefined {
574
+ try {
575
+ const pkgJsonPath = createRequire(import.meta.url).resolve('@wecom/cli/package.json')
576
+ return resolveWecomBinFromPackage(pkgJsonPath)
577
+ } catch {
578
+ return undefined
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Read a `@wecom/cli` package.json and return its wecom-cli bin path.
584
+ * @param pkgJsonPath - absolute path to that package.json.
585
+ * @returns the absolute launcher path, or undefined when `bin` is missing.
586
+ */
587
+ export function resolveWecomBinFromPackage(pkgJsonPath: string): string | undefined {
588
+ const raw = readFileSync(pkgJsonPath, 'utf8')
589
+ const pkg = JSON.parse(raw) as { bin?: string | Record<string, string> }
590
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['wecom-cli']
591
+ if (rel === undefined || rel === '') return undefined
592
+ const binJs = join(dirname(pkgJsonPath), rel)
593
+ return existsSync(binJs) ? binJs : undefined
594
+ }
595
+
596
+ /**
597
+ * True when PATH already contains a wecom-cli executable.
598
+ * @param env - environment whose PATH to scan.
599
+ * @param platform - target platform name matching Node's `process.platform`.
600
+ * @returns true if an executable is found.
601
+ */
602
+ export function wecomCliOnPath(
603
+ env: NodeJS.ProcessEnv = process.env,
604
+ platform: NodeJS.Platform = process.platform,
605
+ ): boolean {
606
+ const pathValue = env.PATH ?? env.Path ?? ''
607
+ const names = platform === 'win32'
608
+ ? ['wecom-cli.exe', 'wecom-cli.cmd', 'wecom-cli.bat', 'wecom-cli']
609
+ : ['wecom-cli']
610
+ for (const dir of pathValue.split(delimiterFor(platform))) {
611
+ if (dir === '') continue
612
+ for (const name of names) {
613
+ if (existsSync(join(dir, name))) return true
614
+ }
615
+ }
616
+ return false
617
+ }
618
+
619
+ /**
620
+ * Write the deny shim and put it first on PATH, shadowing any other wecom-cli.
621
+ * Every child process of this host — group chats, the GUI, the user's own
622
+ * terminal tools — resolves `wecom-cli` to a command that refuses; the plugin
623
+ * itself never goes through PATH.
624
+ * @param options - home, env, and platform overrides for tests.
625
+ * @returns the shim directory and whether another wecom-cli was shadowed.
626
+ */
627
+ export function ensureOnPath(options?: {
628
+ dshHome?: string
629
+ env?: NodeJS.ProcessEnv
630
+ platform?: NodeJS.Platform
631
+ }): EnsureOnPathResult {
632
+ const env = options?.env ?? process.env
633
+ const platform = options?.platform ?? process.platform
634
+ const shadowed = wecomCliOnPath(env, platform)
635
+ const dshHome = options?.dshHome ?? resolveDshHome(env)
636
+ const shimDir = join(dshHome, SHIM_DIR_NAME)
637
+ writeWecomShim(shimDir, platform)
638
+ prependPath(shimDir, env, platform)
639
+ return { shimDir, shadowed }
640
+ }
641
+
642
+ /**
643
+ * Write `wecom-cli` / `wecom-cli.cmd` that print {@link WECOM_CLI_SHIM_DENY_MESSAGE}
644
+ * and exit 1. The message names the tool that does work, so a model following a
645
+ * skill body's `wecom-cli ...` line learns the supported route from the failure.
646
+ * @param shimDir - directory to create.
647
+ * @param platform - target platform.
648
+ * @returns `shimDir`.
649
+ */
650
+ export function writeWecomShim(
651
+ shimDir: string,
652
+ platform: NodeJS.Platform = process.platform,
653
+ ): string {
654
+ mkdirSync(shimDir, { recursive: true })
655
+ if (platform === 'win32') {
656
+ writeFileSync(
657
+ join(shimDir, 'wecom-cli.cmd'),
658
+ `@echo off\r\necho ${WECOM_CLI_SHIM_DENY_MESSAGE}\r\nexit /b 1\r\n`,
659
+ 'utf8',
660
+ )
661
+ return shimDir
662
+ }
663
+ const posix = join(shimDir, 'wecom-cli')
664
+ writeFileSync(
665
+ posix,
666
+ `#!/bin/sh\necho '${WECOM_CLI_SHIM_DENY_MESSAGE}'\nexit 1\n`,
667
+ { encoding: 'utf8', mode: 0o755 },
668
+ )
669
+ chmodSync(posix, 0o755)
670
+ return shimDir
671
+ }
672
+
673
+ /**
674
+ * Prepend `dir` to PATH on `env`.
675
+ * @param dir - directory to put first.
676
+ * @param env - environment object to mutate.
677
+ * @param platform - target platform (selects the PATH delimiter).
678
+ */
679
+ export function prependPath(
680
+ dir: string,
681
+ env: NodeJS.ProcessEnv = process.env,
682
+ platform: NodeJS.Platform = process.platform,
683
+ ): void {
684
+ const sep = delimiterFor(platform)
685
+ const current = env.PATH ?? env.Path ?? ''
686
+ env.PATH = current === '' ? dir : `${dir}${sep}${current}`
687
+ }
688
+
689
+ /**
690
+ * Map `auth show --status` stdout to a status tag.
691
+ * @param stdout - captured standard output.
692
+ * @returns `authorized` / `unauthorized` / `error`.
693
+ */
694
+ export function parseAuthStatus(stdout: string): AuthStatus {
695
+ const line = stdout.trim().split(/\r?\n/).at(-1)?.trim() ?? ''
696
+ if (line === 'authorized' || line === 'unauthorized') return line
697
+ return 'error'
698
+ }
699
+
700
+ /**
701
+ * Run `wecom-cli auth show --status` against `binJs`.
702
+ * @param binJs - absolute launcher path.
703
+ * @param configDir - credential directory injected for this spawn.
704
+ * @returns the parsed status; spawn failures become `error`.
705
+ */
706
+ export async function probeAuth(binJs: string, configDir: string): Promise<AuthStatus> {
707
+ try {
708
+ const { stdout } = await execFileAsync(process.execPath, [binJs, 'auth', 'show', '--status'], {
709
+ timeout: AUTH_PROBE_TIMEOUT_MS,
710
+ windowsHide: true,
711
+ env: wecomCliEnv(configDir),
712
+ })
713
+ return parseAuthStatus(String(stdout))
714
+ } catch (error) {
715
+ if (error !== null && typeof error === 'object' && 'stdout' in error) {
716
+ const stdout = (error as { stdout: unknown }).stdout
717
+ const parsed = parseAuthStatus(String(stdout ?? ''))
718
+ if (parsed !== 'error') return parsed
719
+ }
720
+ return 'error'
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Hidden `@wecom/cli` argv: `auth init --bot-id/--secret`.
726
+ * Official `--manual` needs a TTY; these flags skip prompts when stderr is not a terminal.
727
+ * @param botId - plugin `botId`.
728
+ * @param secret - plugin `secret`.
729
+ * @returns argv after the launcher path. Do not log this array.
730
+ */
731
+ export function authInitArgv(botId: string, secret: string): string[] {
732
+ return ['auth', 'init', '--bot-id', botId.trim(), '--secret', secret.trim()]
733
+ }
734
+
735
+ /**
736
+ * Seed wecom-cli credentials from the plugin Bot ID / Secret via hidden `--bot-id/--secret`.
737
+ * Does not log the secret. Stdio stays piped so the CLI sees a non-TTY stderr.
738
+ * @param binJs - absolute launcher path.
739
+ * @param botId - plugin `botId`.
740
+ * @param secret - plugin `secret`.
741
+ * @param configDir - credential directory the seeded credentials are written to.
742
+ * @returns `undefined` when the process exits 0; otherwise an error string with no secret value.
743
+ */
744
+ export async function trySeedAuth(
745
+ binJs: string,
746
+ botId: string,
747
+ secret: string,
748
+ configDir: string,
749
+ ): Promise<string | undefined> {
750
+ const id = botId.trim()
751
+ const sec = secret.trim()
752
+ if (id === '' || sec === '') {
753
+ return AUTH_INIT_MISSING_MESSAGE
754
+ }
755
+ try {
756
+ await execFileAsync(process.execPath, [binJs, ...authInitArgv(id, sec)], {
757
+ timeout: AUTH_INIT_TIMEOUT_MS,
758
+ windowsHide: true,
759
+ encoding: 'utf8',
760
+ env: wecomCliEnv(configDir),
761
+ })
762
+ return undefined
763
+ } catch {
764
+ // Credential check failed, timeout, or CLI rejected the hidden flags.
765
+ return authInitFailedMessage(configDir)
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Reject argv that would re-authorize the CLI. QR / `auth init` creates a NEW
771
+ * bot; credentials are the plugin's job, and `--bot-id/--secret` never belong
772
+ * in a model-supplied command.
773
+ * @param argv - arguments after the `wecom-cli` name.
774
+ * @returns a model-facing reason, or undefined when the command may run.
775
+ */
776
+ export function argvForbiddenAuth(argv: readonly string[]): string | undefined {
777
+ const tokens = new Set(argv.map(token => token.trim().toLowerCase()))
778
+ if (tokens.has('auth') && tokens.has('init')) {
779
+ return '禁止 auth init:重新授权会新建智能机器人。凭证由 im-bridge 用已有 botId/密钥维护;报 853004 时直接重试业务命令。'
780
+ }
781
+ if (tokens.has('--bot-id') || tokens.has('--secret')) {
782
+ return '禁止在 argv 里传 --bot-id/--secret。'
783
+ }
784
+ return undefined
785
+ }
786
+
787
+ /**
788
+ * Validate the model's tool arguments.
789
+ * @param args - raw tool arguments.
790
+ * @returns the argv array.
791
+ * @throws when `argv` is missing, empty, or not all strings.
792
+ */
793
+ export function parseWecomCliArgs(args: unknown): string[] {
794
+ const argv = typeof args === 'object' && args !== null
795
+ ? (args as { argv?: unknown }).argv
796
+ : undefined
797
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some(item => typeof item !== 'string')) {
798
+ throw new Error(`${WECOM_CLI_TOOL_NAME} 需要 argv:非空字符串数组,例如 ["message","aibot","sessions","list"]`)
799
+ }
800
+ return argv as string[]
801
+ }
802
+
803
+ /**
804
+ * Clip `text` to a byte ceiling; a split multibyte character becomes U+FFFD.
805
+ * @param text - captured stream contents.
806
+ * @param limit - byte ceiling.
807
+ * @returns `text`, or a truncated copy that states the original byte length.
808
+ */
809
+ export function clipOutput(text: string, limit: number = WECOM_CLI_TOOL_MAX_OUTPUT_BYTES): string {
810
+ const bytes = Buffer.from(text, 'utf8')
811
+ if (bytes.byteLength <= limit) return text
812
+ const head = new TextDecoder().decode(bytes.subarray(0, limit))
813
+ return `${head}\n…(输出已截断,原始 ${String(bytes.byteLength)} 字节)`
814
+ }
815
+
816
+ /**
817
+ * Run one office command directly against `binJs`, bypassing PATH and the deny shim.
818
+ * A non-zero exit is a domain result, not a throw; cancellation propagates.
819
+ * @param binJs - absolute launcher path.
820
+ * @param argv - arguments after the `wecom-cli` name.
821
+ * @param configDir - credential directory injected for this spawn.
822
+ * @param signal - caller cancellation from the tool execution.
823
+ * @returns clipped stdout/stderr and the exit code.
824
+ */
825
+ export async function runWecomCli(
826
+ binJs: string,
827
+ argv: readonly string[],
828
+ configDir: string,
829
+ signal?: AbortSignal,
830
+ ): Promise<WecomCliRun> {
831
+ try {
832
+ const { stdout, stderr } = await execFileAsync(process.execPath, [binJs, ...argv], {
833
+ timeout: WECOM_CLI_TOOL_TIMEOUT_MS,
834
+ windowsHide: true,
835
+ encoding: 'utf8',
836
+ maxBuffer: WECOM_CLI_TOOL_MAX_OUTPUT_BYTES * 4,
837
+ env: wecomCliEnv(configDir),
838
+ signal,
839
+ })
840
+ return { stdout: clipOutput(String(stdout)), stderr: clipOutput(String(stderr)), exitCode: 0 }
841
+ } catch (error) {
842
+ if (signal?.aborted === true) throw error
843
+ const failure = error as { stdout?: unknown; stderr?: unknown; code?: unknown; message?: unknown }
844
+ const stderr = String(failure.stderr ?? '') || String(failure.message ?? '')
845
+ return {
846
+ stdout: clipOutput(String(failure.stdout ?? '')),
847
+ stderr: clipOutput(stderr),
848
+ exitCode: typeof failure.code === 'number' ? failure.code : 1,
849
+ }
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Model-facing text for one run: stdout, then stderr, then the exit code.
855
+ * @param value - completed run.
856
+ * @returns the rendered block.
857
+ */
858
+ export function renderWecomCliRun(value: WecomCliRun): string {
859
+ const parts: string[] = []
860
+ if (value.stdout.trim() !== '') parts.push(value.stdout.trimEnd())
861
+ if (value.stderr.trim() !== '') parts.push(`[stderr]\n${value.stderr.trimEnd()}`)
862
+ parts.push(`[exit code: ${String(value.exitCode)}]`)
863
+ return parts.join('\n')
864
+ }
865
+
866
+ /**
867
+ * Build the gated office tool. Registering it on an Agent context is the only
868
+ * way a model reaches wecom-cli; PATH resolves to the deny shim everywhere.
869
+ * @param binJs - absolute launcher path.
870
+ * @param configDir - credential directory injected into every run.
871
+ * @returns the duck-typed `ctx.tools.register()` payload.
872
+ */
873
+ export function wecomCliToolDefinition(binJs: string, configDir: string): RuntimeToolRegistration {
874
+ return {
875
+ name: WECOM_CLI_TOOL_NAME,
876
+ description: '执行企业微信办公命令(wecom-cli)。argv 是 wecom-cli 之后的参数,例如 ["message","aibot","sessions","list"]。禁止 auth init 与扫码授权;不要用 pwsh/bash/npx 运行 wecom-cli。',
877
+ parameters: {
878
+ type: 'object',
879
+ properties: {
880
+ argv: {
881
+ type: 'array',
882
+ items: { type: 'string' },
883
+ description: 'wecom-cli 之后的参数,逐个元素,不要拼成一整条命令行。',
884
+ },
885
+ },
886
+ required: ['argv'],
887
+ },
888
+ output: {
889
+ schema: {
890
+ type: 'object',
891
+ properties: {
892
+ stdout: { type: 'string' },
893
+ stderr: { type: 'string' },
894
+ exitCode: { type: 'number' },
895
+ },
896
+ required: ['stdout', 'stderr', 'exitCode'],
897
+ },
898
+ render: (_args, value) => [{ type: 'text', text: renderWecomCliRun(value as WecomCliRun) }],
899
+ },
900
+ timeoutMs: WECOM_CLI_TOOL_TIMEOUT_MS,
901
+ async execute(args, exec) {
902
+ const argv = parseWecomCliArgs(args)
903
+ const forbidden = argvForbiddenAuth(argv)
904
+ if (forbidden !== undefined) throw new Error(forbidden)
905
+ exec.signal.throwIfAborted()
906
+ return runWecomCli(binJs, argv, configDir, exec.signal)
907
+ },
908
+ presentCall: (args) => {
909
+ const argv = typeof args === 'object' && args !== null ? (args as { argv?: unknown }).argv : undefined
910
+ const shown = Array.isArray(argv) ? argv.filter(item => typeof item === 'string').join(' ') : ''
911
+ return { card: 'terminal', title: `wecom-cli ${shown}`.trimEnd() }
912
+ },
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Register the gated office tool on this Agent's tools layer. Must be called on
918
+ * `agentCtx`: a host context would register it globally, exposing it to the GUI
919
+ * and to group chats.
920
+ * @param agentCtx - the Agent-scoped Cordis context.
921
+ * @param binJs - absolute launcher path.
922
+ * @param configDir - credential directory injected into every run.
923
+ * @returns true when the tool was registered.
924
+ */
925
+ export function registerWecomCliTool(
926
+ agentCtx: AgentServiceHost,
927
+ binJs: string,
928
+ configDir: string,
929
+ ): boolean {
930
+ const registry = toolsRegisterOf(agentCtx)
931
+ if (registry === undefined) {
932
+ console.warn(`[im-bridge] ${TOOLS_SERVICE_MISSING_MESSAGE}`)
933
+ return false
934
+ }
935
+ registry.register(wecomCliToolDefinition(binJs, configDir))
936
+ return true
937
+ }
938
+
939
+ function toolsRegisterOf(agentCtx: AgentServiceHost): { register(tool: RuntimeToolRegistration): () => void } | undefined {
940
+ const tools = agentCtx.get('tools')
941
+ if (tools === undefined || tools === null || typeof tools !== 'object') return undefined
942
+ const register = (tools as { register?: unknown }).register
943
+ if (typeof register !== 'function') return undefined
944
+ return tools as { register(tool: RuntimeToolRegistration): () => void }
945
+ }
946
+
947
+ /**
948
+ * Parse one SKILL.md body. Missing/invalid frontmatter returns undefined.
949
+ * @param text - file contents.
950
+ * @param directory - skill directory (resource base).
951
+ * @returns the skill, or undefined when name/description are unusable.
952
+ */
953
+ export function parseSkillMarkdown(text: string, directory: string): WecomSkill | undefined {
954
+ const split = splitFrontmatter(text)
955
+ if (split === undefined) return undefined
956
+ let data: unknown
957
+ try {
958
+ data = parseYaml(split.yaml)
959
+ } catch {
960
+ return undefined
961
+ }
962
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return undefined
963
+ const record = data as Record<string, unknown>
964
+ const name = typeof record.name === 'string' ? record.name.trim() : ''
965
+ const description = typeof record.description === 'string' ? record.description.trim() : ''
966
+ if (name === '' || description === '' || !SKILL_NAME.test(name)) return undefined
967
+ return { name, description, content: split.body.trim(), directory }
968
+ }
969
+
970
+ /**
971
+ * Load SKILL.md from one-level subdirectories of `dir`.
972
+ * Missing or empty directories return []. Empty `allow` keeps every valid skill.
973
+ * @param dir - managed skills root.
974
+ * @param allow - optional name allow-list.
975
+ * @returns parsed skills in directory order.
976
+ */
977
+ export function loadWecomSkills(dir: string, allow: readonly string[] = []): WecomSkill[] {
978
+ if (!existsSync(dir)) return []
979
+ let entries: string[]
980
+ try {
981
+ entries = readdirSync(dir, { withFileTypes: true })
982
+ .filter(entry => entry.isDirectory())
983
+ .map(entry => entry.name)
984
+ } catch {
985
+ return []
986
+ }
987
+ const allowSet = new Set(allow.map(name => name.trim()).filter(Boolean))
988
+ const skills: WecomSkill[] = []
989
+ for (const name of entries) {
990
+ const directory = join(dir, name)
991
+ const file = join(directory, 'SKILL.md')
992
+ if (!existsSync(file)) continue
993
+ let text: string
994
+ try {
995
+ text = readFileSync(file, 'utf8')
996
+ } catch {
997
+ continue
998
+ }
999
+ const skill = parseSkillMarkdown(text, directory)
1000
+ if (skill === undefined) continue
1001
+ if (allowSet.size > 0 && !allowSet.has(skill.name)) continue
1002
+ skills.push(skill)
1003
+ }
1004
+ return skills
1005
+ }
1006
+
1007
+ function delimiterFor(platform: NodeJS.Platform): string {
1008
+ return platform === 'win32' ? ';' : ':'
1009
+ }
1010
+
1011
+ function splitFrontmatter(text: string): { yaml: string; body: string } | undefined {
1012
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
1013
+ if (match === null || match[1] === undefined) return undefined
1014
+ return { yaml: match[1], body: text.slice(match[0].length) }
1015
+ }