@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,390 +1,642 @@
1
- import path from "node:path"
2
- import { access, readdir, readFile } from "node:fs/promises"
3
- import { pathToFileURL, fileURLToPath } from "node:url"
4
- import { exec } from "node:child_process"
5
- import { promisify } from "node:util"
6
- import { parse as parseYaml } from "yaml"
7
- import { McpRegistry } from "../mcp/registry.mjs"
8
- import { loadCustomCommands, applyCommandTemplate } from "../command/custom-commands.mjs"
9
- import { EventBus } from "../core/events.mjs"
10
- import { EVENT_TYPES } from "../core/constants.mjs"
11
-
12
- const execAsync = promisify(exec)
13
-
14
- const DEFAULT_ALLOWED_COMMANDS = ["git", "node", "npm", "ls", "cat", "date", "pwd", "echo", "which"]
15
- let _allowedCommands = null
16
- let _allowedCommandsSig = null
17
-
18
- function getAllowedCommands(config) {
19
- const extra = config?.skills?.allowed_commands || []
20
- const sig = extra.join(",")
21
- if (_allowedCommands && _allowedCommandsSig === sig) return _allowedCommands
22
- _allowedCommands = new Set([...DEFAULT_ALLOWED_COMMANDS, ...extra])
23
- _allowedCommandsSig = sig
24
- return _allowedCommands
25
- }
26
-
27
- function isCommandAllowed(cmdString, config) {
28
- const allowed = getAllowedCommands(config)
29
- const trimmed = cmdString.trim()
30
- // Extract the base command (first token, strip path)
31
- const firstToken = trimmed.split(/\s+/)[0] || ""
32
- const baseName = path.basename(firstToken)
33
- return allowed.has(baseName)
34
- }
35
-
36
- async function exists(target) {
37
- try {
38
- await access(target)
39
- return true
40
- } catch {
41
- return false
42
- }
43
- }
44
-
45
- /**
46
- * Parse YAML frontmatter from SKILL.md content.
47
- * Returns { meta: {}, body: string }
48
- */
49
- function parseFrontmatter(raw) {
50
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
51
- if (!match) return { meta: {}, body: raw.trim() }
52
- try {
53
- return { meta: parseYaml(match[1]) || {}, body: match[2].trim() }
54
- } catch {
55
- return { meta: {}, body: raw.trim() }
56
- }
57
- }
58
-
59
- /**
60
- * Replace !`command` patterns with command stdout.
61
- * Commands are checked against a whitelist before execution.
62
- */
63
- async function injectDynamicContext(template, cwd, config) {
64
- const pattern = /!\`([^`]+)\`/g
65
- const matches = [...template.matchAll(pattern)]
66
- if (!matches.length) return template
67
- let result = template
68
- for (const m of matches) {
69
- if (!isCommandAllowed(m[1], config)) {
70
- result = result.replace(m[0], `[blocked: ${m[1]}]`)
71
- EventBus.emit({
72
- type: EVENT_TYPES.LONGAGENT_ALERT,
73
- payload: { kind: "skill_command_blocked", command: m[1] }
74
- }).catch(() => {})
75
- continue
76
- }
77
- try {
78
- const { stdout } = await execAsync(m[1], { cwd, timeout: 10000 })
79
- result = result.replace(m[0], stdout.trim())
80
- } catch {
81
- result = result.replace(m[0], `[command failed: ${m[1]}]`)
82
- }
83
- }
84
- return result
85
- }
86
-
87
- /**
88
- * Load SKILL.md directory-format skills from a directory.
89
- * Scans for <dir>/<name>/SKILL.md
90
- */
91
- async function loadAuxFiles(skillDir) {
92
- const aux = {}
93
- const resolvedSkillDir = path.resolve(skillDir)
94
- try {
95
- const entries = await readdir(skillDir, { withFileTypes: true })
96
- for (const e of entries) {
97
- if (!e.isFile() || e.name === "SKILL.md") continue
98
- const filePath = path.resolve(skillDir, e.name)
99
- // Path traversal protection: ensure file is within skillDir
100
- if (!filePath.startsWith(resolvedSkillDir + path.sep) && filePath !== resolvedSkillDir) {
101
- EventBus.emit({
102
- type: EVENT_TYPES.LONGAGENT_ALERT,
103
- payload: { kind: "skill_path_traversal", file: e.name, skillDir }
104
- }).catch(() => {})
105
- continue
106
- }
107
- aux[e.name] = filePath
108
- }
109
- } catch { /* ignore */ }
110
- return aux
111
- }
112
-
113
- async function loadSkillDirs(dir, scope) {
114
- if (!(await exists(dir))) return []
115
- const entries = await readdir(dir, { withFileTypes: true })
116
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
117
- const skills = []
118
- for (const name of dirs) {
119
- const skillDir = path.join(dir, name)
120
- const mdPath = path.join(skillDir, "SKILL.md")
121
- if (!(await exists(mdPath))) continue
122
- try {
123
- const raw = await readFile(mdPath, "utf8")
124
- const { meta, body } = parseFrontmatter(raw)
125
- const auxFiles = await loadAuxFiles(skillDir)
126
- skills.push({
127
- name: meta.name || name,
128
- description: meta.description || name,
129
- type: "skill_md",
130
- scope,
131
- source: mdPath,
132
- skillDir,
133
- template: body,
134
- auxFiles,
135
- disableModelInvocation: !!meta["disable-model-invocation"],
136
- userInvocable: meta["user-invocable"] !== false,
137
- allowedTools: meta["allowed-tools"] || null,
138
- model: meta.model || null,
139
- contextFork: !!meta["context-fork"]
140
- })
141
- } catch { /* skip broken */ }
142
- }
143
- return skills
144
- }
145
-
146
- /**
147
- * Load .mjs programmable skills from a directory.
148
- * Each .mjs file should export: { name, description, run(ctx) }
149
- * run() returns a string prompt to send to the model.
150
- */
151
- async function loadMjsSkills(dir, scope) {
152
- if (!(await exists(dir))) return []
153
- const entries = await readdir(dir, { withFileTypes: true })
154
- const files = entries
155
- .filter((e) => e.isFile() && e.name.endsWith(".mjs"))
156
- .map((e) => e.name)
157
- .sort()
158
-
159
- const skills = []
160
- for (const file of files) {
161
- const full = path.join(dir, file)
162
- try {
163
- const mod = await import(pathToFileURL(full).href)
164
- const name = mod.name || path.basename(file, ".mjs")
165
- skills.push({
166
- name,
167
- description: mod.description || name,
168
- type: "mjs",
169
- scope,
170
- source: full,
171
- run: typeof mod.run === "function" ? mod.run : null
172
- })
173
- } catch {
174
- // Skip broken skill files silently
175
- }
176
- }
177
- return skills
178
- }
179
-
180
- /**
181
- * Convert custom commands (.md templates) to skill format.
182
- */
183
- function customCommandsToSkills(commands) {
184
- return commands.map((cmd) => ({
185
- name: cmd.name,
186
- description: `custom command (${cmd.scope})`,
187
- type: "template",
188
- scope: cmd.scope,
189
- source: cmd.source,
190
- template: cmd.template
191
- }))
192
- }
193
-
194
- /**
195
- * Convert MCP prompts to skill format.
196
- */
197
- function mcpPromptsToSkills(prompts) {
198
- return prompts.map((p) => ({
199
- name: p.name,
200
- description: p.description || `${p.server}:${p.name}`,
201
- type: "mcp_prompt",
202
- scope: "mcp",
203
- server: p.server,
204
- promptId: p.id,
205
- arguments: p.arguments || []
206
- }))
207
- }
208
-
209
- const state = {
210
- skills: new Map(),
211
- loaded: false
212
- }
213
-
214
- export const SkillRegistry = {
215
- /**
216
- * Load all skills from all sources.
217
- */
218
- async initialize(config, cwd = process.cwd()) {
219
- state.skills.clear()
220
-
221
- // Source 0: Built-in skills (shipped with kkcode)
222
- const builtinDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "builtin")
223
- const builtinSkills = await loadMjsSkills(builtinDir, "builtin")
224
- for (const skill of builtinSkills) {
225
- state.skills.set(skill.name, skill)
226
- }
227
-
228
- // Source 1: Custom commands (.md templates)
229
- const customCommands = await loadCustomCommands(cwd)
230
- for (const skill of customCommandsToSkills(customCommands)) {
231
- state.skills.set(skill.name, skill)
232
- }
233
-
234
- // Source 2: Programmable skills (.mjs)
235
- const userRoot = process.env.USERPROFILE || process.env.HOME || cwd
236
- const globalSkillDir = path.join(userRoot, ".kkcode", "skills")
237
- const projectSkillDir = path.join(cwd, ".kkcode", "skills")
238
- const [globalSkills, projectSkills, globalSkillMds, projectSkillMds] = await Promise.all([
239
- loadMjsSkills(globalSkillDir, "global"),
240
- loadMjsSkills(projectSkillDir, "project"),
241
- loadSkillDirs(globalSkillDir, "global"),
242
- loadSkillDirs(projectSkillDir, "project")
243
- ])
244
- // Project skills override global skills with same name
245
- for (const skill of [...globalSkills, ...projectSkills, ...globalSkillMds, ...projectSkillMds]) {
246
- state.skills.set(skill.name, skill)
247
- }
248
-
249
- // Source 3: MCP prompts (if MCP is initialized)
250
- if (McpRegistry.isReady()) {
251
- const prompts = McpRegistry.listPrompts()
252
- for (const skill of mcpPromptsToSkills(prompts)) {
253
- // Prefix MCP skills to avoid name collisions
254
- const key = `mcp:${skill.name}`
255
- state.skills.set(key, { ...skill, name: key })
256
- }
257
- }
258
-
259
- state.loaded = true
260
- },
261
-
262
- isReady() {
263
- return state.loaded
264
- },
265
-
266
- list() {
267
- return [...state.skills.values()]
268
- },
269
-
270
- get(name) {
271
- return state.skills.get(name) || null
272
- },
273
-
274
- /**
275
- * Execute a skill and return the expanded prompt string.
276
- */
277
- async execute(name, args = "", context = {}) {
278
- const skill = state.skills.get(name)
279
- if (!skill) return null
280
-
281
- if (skill.type === "mjs" && skill.run) {
282
- // Programmable skill — call run() to get prompt
283
- try {
284
- const result = await skill.run({
285
- args,
286
- cwd: context.cwd || process.cwd(),
287
- mode: context.mode || "agent",
288
- model: context.model || "",
289
- provider: context.provider || ""
290
- })
291
- return result == null ? "" : typeof result === "string" ? result : JSON.stringify(result)
292
- } catch (error) {
293
- return `skill execution error (${name}): ${error?.message || String(error)}`
294
- }
295
- }
296
-
297
- if (skill.type === "template" && skill.template) {
298
- // Template skill — expand $ARGUMENTS, $1, $2, etc.
299
- return applyCommandTemplate(skill.template, args, {
300
- path: context.cwd || process.cwd(),
301
- mode: context.mode || "agent",
302
- provider: context.provider || "",
303
- cwd: context.cwd || process.cwd(),
304
- project: path.basename(context.cwd || process.cwd())
305
- })
306
- }
307
-
308
- if (skill.type === "skill_md" && skill.template) {
309
- const cwd = context.cwd || process.cwd()
310
- let prompt = applyCommandTemplate(skill.template, args, {
311
- path: cwd, mode: context.mode || "agent",
312
- provider: context.provider || "", cwd, project: path.basename(cwd)
313
- })
314
- // Resolve $FILE{name} references to auxiliary file contents
315
- if (skill.auxFiles) {
316
- const resolvedSkillDir = path.resolve(skill.skillDir)
317
- const filePattern = /\$FILE\{([^}]+)\}/g
318
- const fileMatches = [...prompt.matchAll(filePattern)]
319
- for (const m of fileMatches) {
320
- const filePath = skill.auxFiles[m[1]]
321
- if (filePath) {
322
- // Path traversal protection for $FILE{} references
323
- const resolvedFile = path.resolve(filePath)
324
- if (!resolvedFile.startsWith(resolvedSkillDir + path.sep)) {
325
- prompt = prompt.replace(m[0], `[blocked: path traversal: ${m[1]}]`)
326
- EventBus.emit({
327
- type: EVENT_TYPES.LONGAGENT_ALERT,
328
- payload: { kind: "skill_path_traversal", file: m[1], skillDir: skill.skillDir }
329
- }).catch(() => {})
330
- continue
331
- }
332
- try {
333
- const content = await readFile(filePath, "utf8")
334
- prompt = prompt.replace(m[0], content.trim())
335
- } catch {
336
- prompt = prompt.replace(m[0], `[file not found: ${m[1]}]`)
337
- }
338
- }
339
- }
340
- }
341
- prompt = await injectDynamicContext(prompt, cwd, context.config)
342
- if (skill.contextFork) {
343
- return { prompt, contextFork: true, model: skill.model }
344
- }
345
- return prompt
346
- }
347
-
348
- if (skill.type === "mcp_prompt" && skill.promptId) {
349
- // MCP prompt fetch from server
350
- const promptArgs = {}
351
- if (args) {
352
- // Simple: pass entire args string as first argument
353
- const argDefs = skill.arguments || []
354
- if (argDefs.length === 1) {
355
- promptArgs[argDefs[0].name] = args
356
- } else if (argDefs.length > 1) {
357
- // Split args by spaces for multiple arguments
358
- const tokens = args.split(/\s+/)
359
- for (let i = 0; i < argDefs.length && i < tokens.length; i++) {
360
- promptArgs[argDefs[i].name] = tokens[i]
361
- }
362
- }
363
- }
364
- const result = await McpRegistry.getPrompt(skill.promptId, promptArgs)
365
- // MCP prompt result: { messages: [{ role, content: { type, text } }] }
366
- if (result?.messages) {
367
- return result.messages
368
- .map((m) => {
369
- if (typeof m.content === "string") return m.content
370
- if (m.content?.text) return m.content.text
371
- return ""
372
- })
373
- .filter(Boolean)
374
- .join("\n\n")
375
- }
376
- return JSON.stringify(result)
377
- }
378
-
379
- return null
380
- },
381
-
382
- /**
383
- * Return skill metadata for system prompt inclusion.
384
- */
385
- listForSystemPrompt() {
386
- return [...state.skills.values()]
387
- .filter((s) => !s.disableModelInvocation)
388
- .map((s) => ({ name: s.name, description: s.description }))
389
- }
390
- }
1
+ import path from "node:path"
2
+ import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises"
3
+ import { pathToFileURL, fileURLToPath } from "node:url"
4
+ import { execFile } from "node:child_process"
5
+ import { promisify } from "node:util"
6
+ import { parse as parseYaml } from "yaml"
7
+ import { McpRegistry } from "../mcp/registry.mjs"
8
+ import { loadCustomCommands, applyCommandTemplate } from "../command/custom-commands.mjs"
9
+ import { EventBus } from "../core/events.mjs"
10
+ import { EVENT_TYPES } from "../core/constants.mjs"
11
+ import { discoverLocalPluginManifests, pluginComponentDirs } from "../plugin/manifest-loader.mjs"
12
+ import { userRootDir } from "../storage/paths.mjs"
13
+
14
+ const execFileAsync = promisify(execFile)
15
+
16
+ const DEFAULT_SKILL_DIR_README = `# kkcode Skills\n\nThis directory stores reusable skills for kkcode.\n\nWhen kkcode starts, skills in this directory are loaded automatically and can be invoked as slash commands (for example: \/code-review).\n\nDefault skill packs include:\n- code-review: structured review checklist for changed files\n- test-plan: lightweight test planning support\n\nAdd your own skills as:\n- .md files (simple templates)\n- .mjs files (programmable)\n- directories with SKILL.md (metadata + templates)`
17
+
18
+ const DEFAULT_SKILL_PACKS = [
19
+ {
20
+ dir: "code-review",
21
+ content: `---\nname: code-review\ndescription: Review code changes and provide risk-oriented recommendations.\nuser-invocable: true\ncontext-fork: false\n---\n请基于以下内容做结构化代码评审:\n- 先给出风险分级(高/中/低)与范围\n- 列出 3 条以内关键问题\n- 给出最小可执行修复建议\n\n输入:\n$ARGUMENTS\n\n如果是目录或文件路径,请先说明覆盖范围后再给建议。`
22
+ },
23
+ {
24
+ dir: "test-plan",
25
+ content: `---\nname: test-plan\ndescription: Generate a compact test plan for task execution.\nuser-invocable: true\ncontext-fork: false\n---\n请为下列任务生成测试计划:\n$ARGUMENTS\n\n请输出:\n1) 关键测试场景(按优先级)\n2) 可执行命令顺序\n3) 关键验证标准\n4) 回归风险与缓解建议`
26
+ }
27
+ ]
28
+
29
+ function toText(v) {
30
+ if (v === undefined || v === null) return ""
31
+ return String(v)
32
+ }
33
+
34
+ function toArray(value) {
35
+ if (Array.isArray(value)) return value
36
+ if (value === undefined || value === null || value === "") return []
37
+ return [value]
38
+ }
39
+
40
+ function toStringArray(value) {
41
+ return toArray(value)
42
+ .flatMap((item) => typeof item === "string" ? item.split(",") : [item])
43
+ .map((item) => typeof item === "string" ? item.trim() : "")
44
+ .filter(Boolean)
45
+ }
46
+
47
+ async function exists(target) {
48
+ try {
49
+ await access(target)
50
+ return true
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ async function writeIfMissing(filePath, content, force = false) {
57
+ if (!force && await exists(filePath)) return false
58
+ await mkdir(path.dirname(filePath), { recursive: true })
59
+ await writeFile(filePath, toText(content), "utf8")
60
+ return true
61
+ }
62
+
63
+ export async function ensureDefaultSkillPack({
64
+ cwd = process.cwd(),
65
+ force = false,
66
+ includeProject = true,
67
+ includeGlobal = true
68
+ } = {}) {
69
+ const targets = []
70
+ if (includeGlobal) {
71
+ targets.push({ scope: "global", dir: path.join(userRootDir(), "skills") })
72
+ }
73
+ if (includeProject) {
74
+ targets.push({ scope: "project", dir: path.join(cwd, ".kkcode", "skills") })
75
+ }
76
+
77
+ const created = []
78
+ for (const target of targets) {
79
+ const createdFiles = []
80
+ const skippedFiles = []
81
+
82
+ if (await writeIfMissing(path.join(target.dir, "README.md"), DEFAULT_SKILL_DIR_README, force)) {
83
+ createdFiles.push("README.md")
84
+ } else {
85
+ skippedFiles.push("README.md")
86
+ }
87
+
88
+ for (const pack of DEFAULT_SKILL_PACKS) {
89
+ const filePath = path.join(target.dir, pack.dir, "SKILL.md")
90
+ if (await writeIfMissing(filePath, pack.content, force)) {
91
+ createdFiles.push(`${pack.dir}/SKILL.md`)
92
+ } else {
93
+ skippedFiles.push(`${pack.dir}/SKILL.md`)
94
+ }
95
+ }
96
+
97
+ created.push({
98
+ scope: target.scope,
99
+ dir: target.dir,
100
+ created: createdFiles,
101
+ skipped: skippedFiles
102
+ })
103
+ }
104
+
105
+ return created
106
+ }
107
+
108
+ const DEFAULT_ALLOWED_COMMANDS = ["git", "node", "npm", "ls", "cat", "date", "pwd", "echo", "which"]
109
+ let _allowedCommands = null
110
+ let _allowedCommandsSig = null
111
+
112
+ function getAllowedCommands(config) {
113
+ const extra = config?.skills?.allowed_commands || []
114
+ const sig = extra.join(",")
115
+ if (_allowedCommands && _allowedCommandsSig === sig) return _allowedCommands
116
+ _allowedCommands = new Set([...DEFAULT_ALLOWED_COMMANDS, ...extra])
117
+ _allowedCommandsSig = sig
118
+ return _allowedCommands
119
+ }
120
+
121
+ // Shell metacharacters and control chars that enable command chaining / injection
122
+ const SHELL_INJECTION_RE = /[;|&`$(){}]|>\s*>|<\s*<|[\n\r]/
123
+
124
+ function isCommandAllowed(cmdString, config) {
125
+ const allowed = getAllowedCommands(config)
126
+ const trimmed = cmdString.trim()
127
+ if (!trimmed) return false
128
+ // Reject any shell control characters — prevents chaining like `git status; rm -rf /`
129
+ if (SHELL_INJECTION_RE.test(trimmed)) return false
130
+ // Extract the base command (first token, strip path)
131
+ const firstToken = trimmed.split(/\s+/)[0] || ""
132
+ const baseName = path.basename(firstToken)
133
+ return allowed.has(baseName)
134
+ }
135
+
136
+ /**
137
+ * Parse YAML frontmatter from SKILL.md content.
138
+ * Returns { meta: {}, body: string }
139
+ */
140
+ function parseFrontmatter(raw) {
141
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
142
+ if (!match) return { meta: {}, body: raw.trim() }
143
+ try {
144
+ return { meta: parseYaml(match[1]) || {}, body: match[2].trim() }
145
+ } catch {
146
+ return { meta: {}, body: raw.trim() }
147
+ }
148
+ }
149
+
150
+ function normalizeSkillMeta(meta = {}, defaults = {}) {
151
+ const contextValue = typeof meta.context === "string" ? meta.context.trim().toLowerCase() : ""
152
+ const explicitContextFork = meta["context-fork"] === true || meta.contextFork === true
153
+ const contextFork = explicitContextFork || contextValue === "fork"
154
+ const rawModel = typeof meta.model === "string" ? meta.model.trim() : meta.model
155
+ const model = rawModel && rawModel.toLowerCase() === "inherit" ? null : rawModel || null
156
+ const allowedTools = toStringArray(meta["allowed-tools"] ?? meta.allowedTools ?? meta.tools)
157
+
158
+ return {
159
+ disableModelInvocation: !!meta["disable-model-invocation"],
160
+ userInvocable: meta["user-invocable"] !== false,
161
+ allowedTools: allowedTools.length ? allowedTools : null,
162
+ model,
163
+ contextFork,
164
+ context: contextFork ? "fork" : contextValue === "inline" ? "inline" : null,
165
+ whenToUse: meta.when_to_use || meta["when-to-use"] || null,
166
+ argumentHint: meta.argument_hint || meta["argument-hint"] || null,
167
+ arguments: Array.isArray(meta.arguments) ? meta.arguments : [],
168
+ agent: meta.agent || null,
169
+ effort: meta.effort || null,
170
+ shell: meta.shell || null,
171
+ hooks: meta.hooks || null,
172
+ paths: toStringArray(meta.paths),
173
+ skillRoot: defaults.skillDir || path.dirname(defaults.source || process.cwd()),
174
+ plugin: defaults.plugin || null
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Load plain .md skills from skill directories.
180
+ * Supports optional YAML frontmatter.
181
+ */
182
+ async function loadMarkdownSkills(dir, scope, plugin = null) {
183
+ if (!(await exists(dir))) return []
184
+ const entries = await readdir(dir, { withFileTypes: true })
185
+ const mdFiles = entries
186
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
187
+ .map((entry) => entry.name)
188
+ .sort()
189
+
190
+ const skills = []
191
+ for (const name of mdFiles) {
192
+ const lowerName = name.toLowerCase()
193
+ if (lowerName === "skill.md" || lowerName === "readme.md") continue
194
+ const filePath = path.join(dir, name)
195
+ try {
196
+ const raw = await readFile(filePath, "utf8")
197
+ const trimmed = raw.trim()
198
+ if (!trimmed) continue
199
+ const { meta, body } = parseFrontmatter(trimmed)
200
+ const normalized = normalizeSkillMeta(meta, {
201
+ skillDir: path.dirname(filePath),
202
+ source: filePath,
203
+ plugin
204
+ })
205
+ skills.push({
206
+ name: meta.name || path.basename(name, ".md"),
207
+ description: meta.description || path.basename(name, ".md"),
208
+ type: "skill_md",
209
+ scope,
210
+ source: filePath,
211
+ skillDir: path.dirname(filePath),
212
+ template: body,
213
+ auxFiles: {},
214
+ ...normalized
215
+ })
216
+ } catch {
217
+ // skip invalid markdown skill files
218
+ }
219
+ }
220
+ return skills
221
+ }
222
+
223
+ /**
224
+ * Load .mjs programmable skills from a directory.
225
+ * Each .mjs file should export: { name, description, run(ctx) }
226
+ * run() returns a string prompt to send to the model.
227
+ */
228
+ async function loadMjsSkills(dir, scope, plugin = null) {
229
+ if (!(await exists(dir))) return []
230
+ const resolvedDir = path.resolve(dir)
231
+ const entries = await readdir(dir, { withFileTypes: true })
232
+ const files = entries
233
+ .filter((e) => e.isFile() && e.name.endsWith(".mjs"))
234
+ .map((e) => e.name)
235
+ .sort()
236
+
237
+ const skills = []
238
+ for (const file of files) {
239
+ const full = path.resolve(dir, file)
240
+ // Path boundary check: ensure resolved path is within expected directory
241
+ if (!full.startsWith(resolvedDir + path.sep) && full !== resolvedDir) continue
242
+ try {
243
+ const mod = await import(pathToFileURL(full).href)
244
+ const name = mod.name || path.basename(file, ".mjs")
245
+ skills.push({
246
+ name,
247
+ description: mod.description || name,
248
+ type: "mjs",
249
+ scope,
250
+ source: full,
251
+ run: typeof mod.run === "function" ? mod.run : null,
252
+ skillRoot: path.dirname(full),
253
+ plugin
254
+ })
255
+ } catch {
256
+ // Skip broken skill files silently
257
+ }
258
+ }
259
+ return skills
260
+ }
261
+
262
+ function shellTokenize(input) {
263
+ const tokens = []
264
+ let current = ""
265
+ let inQuote = null
266
+ for (const ch of input) {
267
+ if (inQuote) {
268
+ if (ch === inQuote) { inQuote = null; continue }
269
+ current += ch
270
+ } else if (ch === '"' || ch === "'") {
271
+ inQuote = ch
272
+ } else if (/\s/.test(ch)) {
273
+ if (current) { tokens.push(current); current = "" }
274
+ } else {
275
+ current += ch
276
+ }
277
+ }
278
+ if (current) tokens.push(current)
279
+ return tokens
280
+ }
281
+
282
+ /**
283
+ * Replace !`command` patterns with command stdout.
284
+ * Commands are checked against a whitelist before execution.
285
+ */
286
+ async function injectDynamicContext(template, cwd, config) {
287
+ const pattern = /!\`([^`]+)\`/g
288
+ const matches = [...template.matchAll(pattern)]
289
+ if (!matches.length) return template
290
+ let result = template
291
+ for (const m of matches) {
292
+ if (!isCommandAllowed(m[1], config)) {
293
+ result = result.replace(m[0], `[blocked: ${m[1]}]`)
294
+ EventBus.emit({
295
+ type: EVENT_TYPES.LONGAGENT_ALERT,
296
+ payload: { kind: "skill_command_blocked", command: m[1] }
297
+ }).catch(() => {})
298
+ continue
299
+ }
300
+ try {
301
+ const tokens = shellTokenize(m[1].trim())
302
+ const cmd = tokens[0]
303
+ const cmdArgs = tokens.slice(1)
304
+ const { stdout } = await execFileAsync(cmd, cmdArgs, { cwd, timeout: 10000 })
305
+ result = result.replace(m[0], stdout.trim())
306
+ } catch {
307
+ result = result.replace(m[0], `[command failed: ${m[1]}]`)
308
+ }
309
+ }
310
+ return result
311
+ }
312
+
313
+ /**
314
+ * Load SKILL.md directory-format skills from a directory.
315
+ * Scans for <dir>/<name>/SKILL.md
316
+ */
317
+ async function loadAuxFiles(skillDir) {
318
+ const aux = {}
319
+ const resolvedSkillDir = path.resolve(skillDir)
320
+ try {
321
+ const entries = await readdir(skillDir, { withFileTypes: true })
322
+ for (const e of entries) {
323
+ if (!e.isFile() || e.name === "SKILL.md") continue
324
+ const filePath = path.resolve(skillDir, e.name)
325
+ // Path traversal protection: ensure file is within skillDir
326
+ if (!filePath.startsWith(resolvedSkillDir + path.sep) && filePath !== resolvedSkillDir) {
327
+ EventBus.emit({
328
+ type: EVENT_TYPES.LONGAGENT_ALERT,
329
+ payload: { kind: "skill_path_traversal", file: e.name, skillDir }
330
+ }).catch(() => {})
331
+ continue
332
+ }
333
+ aux[e.name] = filePath
334
+ }
335
+ } catch { /* ignore */ }
336
+ return aux
337
+ }
338
+
339
+ async function loadSkillDirs(dir, scope, plugin = null) {
340
+ if (!(await exists(dir))) return []
341
+ const entries = await readdir(dir, { withFileTypes: true })
342
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
343
+ const skills = []
344
+ for (const name of dirs) {
345
+ const skillDir = path.join(dir, name)
346
+ const mdPath = path.join(skillDir, "SKILL.md")
347
+ if (!(await exists(mdPath))) continue
348
+ try {
349
+ const raw = await readFile(mdPath, "utf8")
350
+ const { meta, body } = parseFrontmatter(raw)
351
+ const auxFiles = await loadAuxFiles(skillDir)
352
+ const normalized = normalizeSkillMeta(meta, {
353
+ skillDir,
354
+ source: mdPath,
355
+ plugin
356
+ })
357
+ skills.push({
358
+ name: meta.name || name,
359
+ description: meta.description || name,
360
+ type: "skill_md",
361
+ scope,
362
+ source: mdPath,
363
+ skillDir,
364
+ template: body,
365
+ auxFiles,
366
+ ...normalized
367
+ })
368
+ } catch { /* skip broken */ }
369
+ }
370
+ return skills
371
+ }
372
+
373
+ /**
374
+ * Convert custom commands (.md templates) to skill format.
375
+ */
376
+ function customCommandsToSkills(commands) {
377
+ return commands.map((cmd) => ({
378
+ name: cmd.name,
379
+ description: `custom command (${cmd.scope})`,
380
+ type: "template",
381
+ scope: cmd.scope,
382
+ source: cmd.source,
383
+ template: cmd.template
384
+ }))
385
+ }
386
+
387
+ /**
388
+ * Convert MCP prompts to skill format.
389
+ */
390
+ function mcpPromptsToSkills(prompts) {
391
+ return prompts.map((p) => ({
392
+ name: p.name,
393
+ description: p.description || `${p.server}:${p.name}`,
394
+ type: "mcp_prompt",
395
+ scope: "mcp",
396
+ server: p.server,
397
+ promptId: p.id,
398
+ arguments: p.arguments || []
399
+ }))
400
+ }
401
+
402
+ const state = {
403
+ skills: new Map(),
404
+ loaded: false,
405
+ plugins: [],
406
+ pluginErrors: []
407
+ }
408
+
409
+ export const SkillRegistry = {
410
+ /**
411
+ * Load all skills from all sources.
412
+ */
413
+ async initialize(config, cwd = process.cwd()) {
414
+ state.skills.clear()
415
+ state.plugins = []
416
+ state.pluginErrors = []
417
+ const autoSeed = config?.skills?.auto_seed !== false
418
+ if (autoSeed) {
419
+ try {
420
+ await ensureDefaultSkillPack({ cwd, force: false, includeProject: true, includeGlobal: true })
421
+ } catch {
422
+ // Ignore seed failures (e.g., read-only mode)
423
+ }
424
+ }
425
+
426
+ // Respect skills.enabled config — if explicitly false, skip all loading
427
+ if (config?.skills?.enabled === false) {
428
+ state.loaded = true
429
+ return
430
+ }
431
+
432
+ // Source 0: Built-in skills (shipped with kkcode)
433
+ const builtinDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "builtin")
434
+ const builtinSkills = await loadMjsSkills(builtinDir, "builtin")
435
+ for (const skill of builtinSkills) {
436
+ state.skills.set(skill.name, skill)
437
+ }
438
+
439
+ // Source 1: Custom commands (.md templates)
440
+ const customCommands = await loadCustomCommands(cwd)
441
+ for (const skill of customCommandsToSkills(customCommands)) {
442
+ state.skills.set(skill.name, skill)
443
+ }
444
+
445
+ // Source 2: Programmable skills (.mjs) + SKILL.md directories
446
+ const userRoot = userRootDir()
447
+ const pluginManifestState = await discoverLocalPluginManifests(cwd)
448
+ state.plugins = pluginManifestState.plugins
449
+ state.pluginErrors = pluginManifestState.errors
450
+ const rawCustomDirs = Array.isArray(config?.skills?.dirs) ? config.skills.dirs : []
451
+ // Default directories: global (~/.kkcode/skills) + project (.kkcode/skills)
452
+ const defaultDirs = [
453
+ { dir: path.join(userRoot, "skills"), scope: "global" },
454
+ { dir: path.join(cwd, ".kkcode", "skills"), scope: "project" }
455
+ ]
456
+ const pluginDirs = pluginComponentDirs(state.plugins, "skills")
457
+ // Custom dirs from config (resolve relative to cwd)
458
+ const extraDirs = rawCustomDirs
459
+ .filter((d) => typeof d === "string" && d.trim().length > 0)
460
+ .map((d) => {
461
+ const trimmed = d.trim()
462
+ return {
463
+ dir: path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed),
464
+ scope: "custom"
465
+ }
466
+ })
467
+ const seenDirSet = new Set()
468
+ const allSkillDirs = [...pluginDirs, ...defaultDirs, ...extraDirs].filter((entry) => {
469
+ const resolved = path.resolve(entry.dir)
470
+ if (seenDirSet.has(resolved)) return false
471
+ seenDirSet.add(resolved)
472
+ return true
473
+ })
474
+
475
+ const loadPromises = allSkillDirs.flatMap(({ dir, scope, plugin = null }) => [
476
+ loadMarkdownSkills(dir, scope, plugin),
477
+ loadMjsSkills(dir, scope, plugin),
478
+ loadSkillDirs(dir, scope, plugin)
479
+ ])
480
+ const results = await Promise.all(loadPromises)
481
+ for (const skills of results) {
482
+ for (const skill of skills) {
483
+ state.skills.set(skill.name, skill)
484
+ }
485
+ }
486
+
487
+ // Source 3: MCP prompts (if MCP is initialized)
488
+ if (McpRegistry.isReady()) {
489
+ const prompts = McpRegistry.listPrompts()
490
+ for (const skill of mcpPromptsToSkills(prompts)) {
491
+ // Include server name to avoid cross-server name collisions
492
+ const key = `mcp:${skill.server}:${skill.name}`
493
+ state.skills.set(key, { ...skill, name: key })
494
+ }
495
+ }
496
+
497
+ state.loaded = true
498
+ },
499
+
500
+ isReady() {
501
+ return state.loaded
502
+ },
503
+
504
+ list() {
505
+ return [...state.skills.values()]
506
+ },
507
+
508
+ get(name) {
509
+ return state.skills.get(name) || null
510
+ },
511
+
512
+ /**
513
+ * Execute a skill and return the expanded prompt string.
514
+ */
515
+ async execute(name, args = "", context = {}) {
516
+ const skill = state.skills.get(name)
517
+ if (!skill) return null
518
+
519
+ if (skill.type === "mjs" && skill.run) {
520
+ // Programmable skill — call run() to get prompt
521
+ try {
522
+ const result = await skill.run({
523
+ args,
524
+ cwd: context.cwd || process.cwd(),
525
+ mode: context.mode || "agent",
526
+ model: context.model || "",
527
+ provider: context.provider || "",
528
+ config: context.config || null
529
+ })
530
+ return result == null ? "" : typeof result === "string" ? result : JSON.stringify(result)
531
+ } catch (error) {
532
+ return `skill execution error (${name}): ${error?.message || String(error)}`
533
+ }
534
+ }
535
+
536
+ if (skill.type === "template" && skill.template) {
537
+ // Template skill — expand $ARGUMENTS, $1, $2, etc.
538
+ return applyCommandTemplate(skill.template, args, {
539
+ path: context.cwd || process.cwd(),
540
+ mode: context.mode || "agent",
541
+ provider: context.provider || "",
542
+ cwd: context.cwd || process.cwd(),
543
+ project: path.basename(context.cwd || process.cwd())
544
+ })
545
+ }
546
+
547
+ if (skill.type === "skill_md" && skill.template) {
548
+ const cwd = context.cwd || process.cwd()
549
+ let prompt = applyCommandTemplate(skill.template, args, {
550
+ path: cwd, mode: context.mode || "agent",
551
+ provider: context.provider || "", cwd, project: path.basename(cwd),
552
+ SKILL_ROOT: skill.skillRoot || skill.skillDir || path.dirname(skill.source),
553
+ SKILL_DIR: skill.skillRoot || skill.skillDir || path.dirname(skill.source),
554
+ SKILL_NAME: skill.name,
555
+ ARGUMENT_HINT: skill.argumentHint || "",
556
+ WHEN_TO_USE: skill.whenToUse || ""
557
+ })
558
+ // Resolve $FILE{name} references to auxiliary file contents
559
+ if (skill.auxFiles) {
560
+ const resolvedSkillDir = path.resolve(skill.skillDir)
561
+ const filePattern = /\$FILE\{([^}]+)\}/g
562
+ const fileMatches = [...prompt.matchAll(filePattern)]
563
+ for (const m of fileMatches) {
564
+ const filePath = skill.auxFiles[m[1]]
565
+ if (filePath) {
566
+ // Path traversal protection for $FILE{} references
567
+ const resolvedFile = path.resolve(filePath)
568
+ if (!resolvedFile.startsWith(resolvedSkillDir + path.sep)) {
569
+ prompt = prompt.replace(m[0], `[blocked: path traversal: ${m[1]}]`)
570
+ EventBus.emit({
571
+ type: EVENT_TYPES.LONGAGENT_ALERT,
572
+ payload: { kind: "skill_path_traversal", file: m[1], skillDir: skill.skillDir }
573
+ }).catch(() => {})
574
+ continue
575
+ }
576
+ try {
577
+ const content = await readFile(filePath, "utf8")
578
+ prompt = prompt.replace(m[0], content.trim())
579
+ } catch {
580
+ prompt = prompt.replace(m[0], `[file not found: ${m[1]}]`)
581
+ }
582
+ }
583
+ }
584
+ }
585
+ prompt = await injectDynamicContext(prompt, cwd, context.config)
586
+ if (skill.contextFork) {
587
+ return { prompt, contextFork: true, model: skill.model }
588
+ }
589
+ return prompt
590
+ }
591
+
592
+ if (skill.type === "mcp_prompt" && skill.promptId) {
593
+ // MCP prompt — fetch from server
594
+ const promptArgs = {}
595
+ if (args) {
596
+ // Simple: pass entire args string as first argument
597
+ const argDefs = skill.arguments || []
598
+ if (argDefs.length === 1) {
599
+ promptArgs[argDefs[0].name] = args
600
+ } else if (argDefs.length > 1) {
601
+ // Split args by spaces for multiple arguments
602
+ const tokens = args.split(/\s+/)
603
+ for (let i = 0; i < argDefs.length && i < tokens.length; i++) {
604
+ promptArgs[argDefs[i].name] = tokens[i]
605
+ }
606
+ }
607
+ }
608
+ const result = await McpRegistry.getPrompt(skill.promptId, promptArgs)
609
+ // MCP prompt result: { messages: [{ role, content: { type, text } }] }
610
+ if (result?.messages) {
611
+ return result.messages
612
+ .map((m) => {
613
+ if (typeof m.content === "string") return m.content
614
+ if (m.content?.text) return m.content.text
615
+ return ""
616
+ })
617
+ .filter(Boolean)
618
+ .join("\n\n")
619
+ }
620
+ return JSON.stringify(result)
621
+ }
622
+
623
+ return null
624
+ },
625
+
626
+ /**
627
+ * Return skill metadata for system prompt inclusion.
628
+ */
629
+ listForSystemPrompt() {
630
+ return [...state.skills.entries()]
631
+ .filter(([key, s]) => !s.disableModelInvocation && !key.startsWith("mcp:"))
632
+ .map(([, s]) => ({ name: s.name, description: s.description }))
633
+ },
634
+
635
+ listPluginManifests() {
636
+ return [...state.plugins]
637
+ },
638
+
639
+ pluginErrors() {
640
+ return [...state.pluginErrors]
641
+ }
642
+ }