@kkelly-offical/kkcode 0.1.3 → 0.1.7

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 (66) hide show
  1. package/README.md +110 -172
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +220 -170
  4. package/src/agent/prompt/bug-hunter.txt +90 -0
  5. package/src/agent/prompt/frontend-designer.txt +58 -0
  6. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  7. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  8. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  9. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  10. package/src/config/defaults.mjs +260 -195
  11. package/src/config/schema.mjs +71 -6
  12. package/src/core/constants.mjs +91 -46
  13. package/src/index.mjs +1 -1
  14. package/src/knowledge/frontend-aesthetics.txt +39 -0
  15. package/src/knowledge/loader.mjs +2 -1
  16. package/src/knowledge/tailwind.txt +12 -3
  17. package/src/mcp/client-http.mjs +141 -157
  18. package/src/mcp/client-sse.mjs +288 -286
  19. package/src/mcp/client-stdio.mjs +533 -451
  20. package/src/mcp/constants.mjs +2 -0
  21. package/src/mcp/registry.mjs +479 -394
  22. package/src/mcp/stdio-framing.mjs +133 -127
  23. package/src/mcp/tool-result.mjs +24 -0
  24. package/src/observability/index.mjs +42 -0
  25. package/src/observability/metrics.mjs +137 -0
  26. package/src/observability/tracer.mjs +137 -0
  27. package/src/orchestration/background-manager.mjs +372 -358
  28. package/src/orchestration/background-worker.mjs +305 -245
  29. package/src/orchestration/longagent-manager.mjs +171 -116
  30. package/src/orchestration/stage-scheduler.mjs +728 -489
  31. package/src/permission/exec-policy.mjs +9 -11
  32. package/src/provider/anthropic.mjs +1 -0
  33. package/src/provider/openai.mjs +340 -339
  34. package/src/provider/retry-policy.mjs +68 -68
  35. package/src/provider/router.mjs +241 -228
  36. package/src/provider/sse.mjs +104 -91
  37. package/src/repl.mjs +59 -7
  38. package/src/session/checkpoint.mjs +66 -3
  39. package/src/session/compaction.mjs +298 -276
  40. package/src/session/engine.mjs +232 -225
  41. package/src/session/longagent-4stage.mjs +460 -0
  42. package/src/session/longagent-hybrid.mjs +1097 -0
  43. package/src/session/longagent-plan.mjs +365 -329
  44. package/src/session/longagent-project-memory.mjs +53 -0
  45. package/src/session/longagent-scaffold.mjs +291 -100
  46. package/src/session/longagent-task-bus.mjs +54 -0
  47. package/src/session/longagent-utils.mjs +472 -0
  48. package/src/session/longagent.mjs +900 -1462
  49. package/src/session/loop.mjs +65 -40
  50. package/src/session/project-context.mjs +30 -0
  51. package/src/session/prompt/agent.txt +25 -0
  52. package/src/session/prompt/plan.txt +31 -9
  53. package/src/session/rollback.mjs +196 -0
  54. package/src/session/store.mjs +519 -503
  55. package/src/session/system-prompt.mjs +273 -260
  56. package/src/session/task-validator.mjs +4 -3
  57. package/src/skill/builtin/design.mjs +76 -0
  58. package/src/skill/builtin/frontend.mjs +8 -0
  59. package/src/skill/registry.mjs +390 -336
  60. package/src/storage/ghost-commit-store.mjs +18 -8
  61. package/src/tool/executor.mjs +11 -0
  62. package/src/tool/git-auto.mjs +0 -19
  63. package/src/tool/question-prompt.mjs +93 -86
  64. package/src/tool/registry.mjs +71 -37
  65. package/src/ui/activity-renderer.mjs +664 -410
  66. package/src/util/git.mjs +23 -0
@@ -1,336 +1,390 @@
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
-
10
- const execAsync = promisify(exec)
11
-
12
- async function exists(target) {
13
- try {
14
- await access(target)
15
- return true
16
- } catch {
17
- return false
18
- }
19
- }
20
-
21
- /**
22
- * Parse YAML frontmatter from SKILL.md content.
23
- * Returns { meta: {}, body: string }
24
- */
25
- function parseFrontmatter(raw) {
26
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
27
- if (!match) return { meta: {}, body: raw.trim() }
28
- try {
29
- return { meta: parseYaml(match[1]) || {}, body: match[2].trim() }
30
- } catch {
31
- return { meta: {}, body: raw.trim() }
32
- }
33
- }
34
-
35
- /**
36
- * Replace !`command` patterns with command stdout.
37
- */
38
- async function injectDynamicContext(template, cwd) {
39
- const pattern = /!\`([^`]+)\`/g
40
- const matches = [...template.matchAll(pattern)]
41
- if (!matches.length) return template
42
- let result = template
43
- for (const m of matches) {
44
- try {
45
- const { stdout } = await execAsync(m[1], { cwd, timeout: 10000 })
46
- result = result.replace(m[0], stdout.trim())
47
- } catch {
48
- result = result.replace(m[0], `[command failed: ${m[1]}]`)
49
- }
50
- }
51
- return result
52
- }
53
-
54
- /**
55
- * Load SKILL.md directory-format skills from a directory.
56
- * Scans for <dir>/<name>/SKILL.md
57
- */
58
- async function loadAuxFiles(skillDir) {
59
- const aux = {}
60
- try {
61
- const entries = await readdir(skillDir, { withFileTypes: true })
62
- for (const e of entries) {
63
- if (!e.isFile() || e.name === "SKILL.md") continue
64
- aux[e.name] = path.join(skillDir, e.name)
65
- }
66
- } catch { /* ignore */ }
67
- return aux
68
- }
69
-
70
- async function loadSkillDirs(dir, scope) {
71
- if (!(await exists(dir))) return []
72
- const entries = await readdir(dir, { withFileTypes: true })
73
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
74
- const skills = []
75
- for (const name of dirs) {
76
- const skillDir = path.join(dir, name)
77
- const mdPath = path.join(skillDir, "SKILL.md")
78
- if (!(await exists(mdPath))) continue
79
- try {
80
- const raw = await readFile(mdPath, "utf8")
81
- const { meta, body } = parseFrontmatter(raw)
82
- const auxFiles = await loadAuxFiles(skillDir)
83
- skills.push({
84
- name: meta.name || name,
85
- description: meta.description || name,
86
- type: "skill_md",
87
- scope,
88
- source: mdPath,
89
- skillDir,
90
- template: body,
91
- auxFiles,
92
- disableModelInvocation: !!meta["disable-model-invocation"],
93
- userInvocable: meta["user-invocable"] !== false,
94
- allowedTools: meta["allowed-tools"] || null,
95
- model: meta.model || null,
96
- contextFork: !!meta["context-fork"]
97
- })
98
- } catch { /* skip broken */ }
99
- }
100
- return skills
101
- }
102
-
103
- /**
104
- * Load .mjs programmable skills from a directory.
105
- * Each .mjs file should export: { name, description, run(ctx) }
106
- * run() returns a string prompt to send to the model.
107
- */
108
- async function loadMjsSkills(dir, scope) {
109
- if (!(await exists(dir))) return []
110
- const entries = await readdir(dir, { withFileTypes: true })
111
- const files = entries
112
- .filter((e) => e.isFile() && e.name.endsWith(".mjs"))
113
- .map((e) => e.name)
114
- .sort()
115
-
116
- const skills = []
117
- for (const file of files) {
118
- const full = path.join(dir, file)
119
- try {
120
- const mod = await import(pathToFileURL(full).href)
121
- const name = mod.name || path.basename(file, ".mjs")
122
- skills.push({
123
- name,
124
- description: mod.description || name,
125
- type: "mjs",
126
- scope,
127
- source: full,
128
- run: typeof mod.run === "function" ? mod.run : null
129
- })
130
- } catch {
131
- // Skip broken skill files silently
132
- }
133
- }
134
- return skills
135
- }
136
-
137
- /**
138
- * Convert custom commands (.md templates) to skill format.
139
- */
140
- function customCommandsToSkills(commands) {
141
- return commands.map((cmd) => ({
142
- name: cmd.name,
143
- description: `custom command (${cmd.scope})`,
144
- type: "template",
145
- scope: cmd.scope,
146
- source: cmd.source,
147
- template: cmd.template
148
- }))
149
- }
150
-
151
- /**
152
- * Convert MCP prompts to skill format.
153
- */
154
- function mcpPromptsToSkills(prompts) {
155
- return prompts.map((p) => ({
156
- name: p.name,
157
- description: p.description || `${p.server}:${p.name}`,
158
- type: "mcp_prompt",
159
- scope: "mcp",
160
- server: p.server,
161
- promptId: p.id,
162
- arguments: p.arguments || []
163
- }))
164
- }
165
-
166
- const state = {
167
- skills: new Map(),
168
- loaded: false
169
- }
170
-
171
- export const SkillRegistry = {
172
- /**
173
- * Load all skills from all sources.
174
- */
175
- async initialize(config, cwd = process.cwd()) {
176
- state.skills.clear()
177
-
178
- // Source 0: Built-in skills (shipped with kkcode)
179
- const builtinDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "builtin")
180
- const builtinSkills = await loadMjsSkills(builtinDir, "builtin")
181
- for (const skill of builtinSkills) {
182
- state.skills.set(skill.name, skill)
183
- }
184
-
185
- // Source 1: Custom commands (.md templates)
186
- const customCommands = await loadCustomCommands(cwd)
187
- for (const skill of customCommandsToSkills(customCommands)) {
188
- state.skills.set(skill.name, skill)
189
- }
190
-
191
- // Source 2: Programmable skills (.mjs)
192
- const userRoot = process.env.USERPROFILE || process.env.HOME || cwd
193
- const globalSkillDir = path.join(userRoot, ".kkcode", "skills")
194
- const projectSkillDir = path.join(cwd, ".kkcode", "skills")
195
- const [globalSkills, projectSkills, globalSkillMds, projectSkillMds] = await Promise.all([
196
- loadMjsSkills(globalSkillDir, "global"),
197
- loadMjsSkills(projectSkillDir, "project"),
198
- loadSkillDirs(globalSkillDir, "global"),
199
- loadSkillDirs(projectSkillDir, "project")
200
- ])
201
- // Project skills override global skills with same name
202
- for (const skill of [...globalSkills, ...projectSkills, ...globalSkillMds, ...projectSkillMds]) {
203
- state.skills.set(skill.name, skill)
204
- }
205
-
206
- // Source 3: MCP prompts (if MCP is initialized)
207
- if (McpRegistry.isReady()) {
208
- const prompts = McpRegistry.listPrompts()
209
- for (const skill of mcpPromptsToSkills(prompts)) {
210
- // Prefix MCP skills to avoid name collisions
211
- const key = `mcp:${skill.name}`
212
- state.skills.set(key, { ...skill, name: key })
213
- }
214
- }
215
-
216
- state.loaded = true
217
- },
218
-
219
- isReady() {
220
- return state.loaded
221
- },
222
-
223
- list() {
224
- return [...state.skills.values()]
225
- },
226
-
227
- get(name) {
228
- return state.skills.get(name) || null
229
- },
230
-
231
- /**
232
- * Execute a skill and return the expanded prompt string.
233
- */
234
- async execute(name, args = "", context = {}) {
235
- const skill = state.skills.get(name)
236
- if (!skill) return null
237
-
238
- if (skill.type === "mjs" && skill.run) {
239
- // Programmable skill — call run() to get prompt
240
- try {
241
- const result = await skill.run({
242
- args,
243
- cwd: context.cwd || process.cwd(),
244
- mode: context.mode || "agent",
245
- model: context.model || "",
246
- provider: context.provider || ""
247
- })
248
- return result == null ? "" : typeof result === "string" ? result : JSON.stringify(result)
249
- } catch (error) {
250
- return `skill execution error (${name}): ${error?.message || String(error)}`
251
- }
252
- }
253
-
254
- if (skill.type === "template" && skill.template) {
255
- // Template skill — expand $ARGUMENTS, $1, $2, etc.
256
- return applyCommandTemplate(skill.template, args, {
257
- path: context.cwd || process.cwd(),
258
- mode: context.mode || "agent",
259
- provider: context.provider || "",
260
- cwd: context.cwd || process.cwd(),
261
- project: path.basename(context.cwd || process.cwd())
262
- })
263
- }
264
-
265
- if (skill.type === "skill_md" && skill.template) {
266
- const cwd = context.cwd || process.cwd()
267
- let prompt = applyCommandTemplate(skill.template, args, {
268
- path: cwd, mode: context.mode || "agent",
269
- provider: context.provider || "", cwd, project: path.basename(cwd)
270
- })
271
- // Resolve $FILE{name} references to auxiliary file contents
272
- if (skill.auxFiles) {
273
- const filePattern = /\$FILE\{([^}]+)\}/g
274
- const fileMatches = [...prompt.matchAll(filePattern)]
275
- for (const m of fileMatches) {
276
- const filePath = skill.auxFiles[m[1]]
277
- if (filePath) {
278
- try {
279
- const content = await readFile(filePath, "utf8")
280
- prompt = prompt.replace(m[0], content.trim())
281
- } catch {
282
- prompt = prompt.replace(m[0], `[file not found: ${m[1]}]`)
283
- }
284
- }
285
- }
286
- }
287
- prompt = await injectDynamicContext(prompt, cwd)
288
- if (skill.contextFork) {
289
- return { prompt, contextFork: true, model: skill.model }
290
- }
291
- return prompt
292
- }
293
-
294
- if (skill.type === "mcp_prompt" && skill.promptId) {
295
- // MCP prompt — fetch from server
296
- const promptArgs = {}
297
- if (args) {
298
- // Simple: pass entire args string as first argument
299
- const argDefs = skill.arguments || []
300
- if (argDefs.length === 1) {
301
- promptArgs[argDefs[0].name] = args
302
- } else if (argDefs.length > 1) {
303
- // Split args by spaces for multiple arguments
304
- const tokens = args.split(/\s+/)
305
- for (let i = 0; i < argDefs.length && i < tokens.length; i++) {
306
- promptArgs[argDefs[i].name] = tokens[i]
307
- }
308
- }
309
- }
310
- const result = await McpRegistry.getPrompt(skill.promptId, promptArgs)
311
- // MCP prompt result: { messages: [{ role, content: { type, text } }] }
312
- if (result?.messages) {
313
- return result.messages
314
- .map((m) => {
315
- if (typeof m.content === "string") return m.content
316
- if (m.content?.text) return m.content.text
317
- return ""
318
- })
319
- .filter(Boolean)
320
- .join("\n\n")
321
- }
322
- return JSON.stringify(result)
323
- }
324
-
325
- return null
326
- },
327
-
328
- /**
329
- * Return skill metadata for system prompt inclusion.
330
- */
331
- listForSystemPrompt() {
332
- return [...state.skills.values()]
333
- .filter((s) => !s.disableModelInvocation)
334
- .map((s) => ({ name: s.name, description: s.description }))
335
- }
336
- }
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
+ }