@meowlynxsea/koi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Skills Loader
3
+ *
4
+ * Full implementation of Claude Code's skill loading system:
5
+ * - Loads skills from ~/.config/koi/skills, ~/.claude/skills, and .claude/skills
6
+ * - Supports SKILL.md format with YAML frontmatter
7
+ * - Conditional skills (path-filtered activation)
8
+ * - Dynamic skill discovery based on file paths
9
+ * - Argument substitution with {{skill.args}}
10
+ * - Shell command execution (!`...` syntax)
11
+ */
12
+
13
+ import path from "path";
14
+ import os from "os";
15
+ import { realpath as nodeRealpath, readdir, stat as fsStat, access, readFile } from "fs/promises";
16
+ import fsSync from "fs";
17
+ import { fileURLToPath } from "url";
18
+ import { parseFrontmatter, parseFrontmatterFields } from "./frontmatter.js";
19
+ import { substituteArguments, substituteEnvVariables, parseArgumentNames } from "./substitution.js";
20
+ import type {
21
+ SkillCommand,
22
+ SkillWithPath,
23
+ SkillSource,
24
+ SkillLoadedFrom,
25
+ BundledSkillDefinition,
26
+ HooksSettings,
27
+ } from "./types.js";
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+
32
+ // Default skills directories
33
+ const USER_SKILLS_DIR = path.join(os.homedir(), ".config", "koi", "skills");
34
+ const USER_CLAUDE_SKILLS_DIR = path.join(os.homedir(), ".claude", "skills");
35
+
36
+ /**
37
+ * Skill state tracking
38
+ */
39
+ interface SkillState {
40
+ unconditional: Map<string, SkillCommand>;
41
+ conditional: Map<string, SkillCommand>;
42
+ activatedConditional: Set<string>;
43
+ dynamic: Map<string, SkillCommand>;
44
+ discoveredDirs: Set<string>;
45
+ }
46
+
47
+ /**
48
+ * Global skill state
49
+ */
50
+ const skillState: SkillState = {
51
+ unconditional: new Map(),
52
+ conditional: new Map(),
53
+ activatedConditional: new Set(),
54
+ dynamic: new Map(),
55
+ discoveredDirs: new Set(),
56
+ };
57
+
58
+ let skillsLoaded = false;
59
+ let loadListeners: (() => void)[] = [];
60
+
61
+ /**
62
+ * Check if skills have been loaded
63
+ */
64
+ export function areSkillsLoaded(): boolean {
65
+ return skillsLoaded;
66
+ }
67
+
68
+ /**
69
+ * Get the skills directory path for a given source
70
+ */
71
+ export function getSkillsPath(source: SkillSource, cwd?: string): string {
72
+ switch (source) {
73
+ case "userSettings":
74
+ return USER_SKILLS_DIR;
75
+ case "projectSettings":
76
+ return cwd ? path.join(cwd, ".claude", "skills") : ".claude/skills";
77
+ case "policySettings":
78
+ return path.join(os.homedir(), ".config", "koi", "policy", "skills");
79
+ case "bundled":
80
+ return path.join(__dirname, "bundled");
81
+ default:
82
+ return USER_SKILLS_DIR;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if a path exists and is a directory
88
+ */
89
+ async function isDirectory(p: string): Promise<boolean> {
90
+ try {
91
+ const fileStat = await fsStat(p);
92
+ return fileStat.isDirectory();
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Check if a path exists
100
+ */
101
+ async function pathExists(p: string): Promise<boolean> {
102
+ try {
103
+ await access(p);
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve real path, handling symlinks
112
+ */
113
+ async function realPath(p: string): Promise<string> {
114
+ try {
115
+ const resolvedPath: string = await nodeRealpath(p);
116
+ return resolvedPath;
117
+ } catch {
118
+ return p;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Find SKILL.md file in a directory (only SKILL.md, not other variants)
124
+ */
125
+ async function findSkillFile(dirPath: string): Promise<string | null> {
126
+ const skillFilePath = path.join(dirPath, "SKILL.md");
127
+ if (await pathExists(skillFilePath)) {
128
+ return skillFilePath;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Recursively find all skill directories
135
+ */
136
+ async function findSkillDirs(
137
+ basePath: string,
138
+ found: Set<string> = new Set()
139
+ ): Promise<string[]> {
140
+ if (!fsSync.existsSync(basePath)) {
141
+ return [];
142
+ }
143
+
144
+ try {
145
+ const entries = await readdir(basePath, { withFileTypes: true });
146
+ const dirs: string[] = [];
147
+
148
+ for (const entry of entries) {
149
+ if (!entry.isDirectory()) continue;
150
+
151
+ const fullPath = path.join(basePath, entry.name);
152
+ const realFullPath = await realPath(fullPath);
153
+
154
+ if (found.has(realFullPath)) continue;
155
+ found.add(realFullPath);
156
+ dirs.push(fullPath);
157
+ }
158
+
159
+ return dirs;
160
+ } catch {
161
+ return [];
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Create a skill command from parsed data (Claude Code style)
167
+ */
168
+ function createSkillCommand(params: {
169
+ skillName: string;
170
+ displayName?: string;
171
+ description: string;
172
+ hasUserSpecifiedDescription: boolean;
173
+ markdownContent: string;
174
+ allowedTools: string[];
175
+ argumentHint?: string;
176
+ argumentNames: string[];
177
+ whenToUse?: string;
178
+ version?: string;
179
+ model?: string;
180
+ disableModelInvocation: boolean;
181
+ userInvocable: boolean;
182
+ source: SkillSource;
183
+ baseDir?: string;
184
+ loadedFrom: SkillLoadedFrom;
185
+ hooks?: HooksSettings;
186
+ executionContext?: "fork" | "inline";
187
+ agent?: string;
188
+ paths?: string[];
189
+ effort?: string;
190
+ }): SkillCommand {
191
+ const {
192
+ skillName,
193
+ description,
194
+ hasUserSpecifiedDescription,
195
+ markdownContent,
196
+ allowedTools,
197
+ argumentHint,
198
+ argumentNames,
199
+ whenToUse,
200
+ version,
201
+ model,
202
+ disableModelInvocation,
203
+ userInvocable,
204
+ source,
205
+ baseDir,
206
+ loadedFrom,
207
+ hooks,
208
+ executionContext,
209
+ agent,
210
+ paths,
211
+ effort,
212
+ } = params;
213
+
214
+ return {
215
+ type: "prompt",
216
+ name: skillName,
217
+ description,
218
+ hasUserSpecifiedDescription,
219
+ allowedTools,
220
+ argumentHint,
221
+ argNames: argumentNames.length > 0 ? argumentNames : undefined,
222
+ whenToUse,
223
+ version,
224
+ model,
225
+ disableModelInvocation,
226
+ userInvocable,
227
+ context: executionContext,
228
+ agent,
229
+ effort,
230
+ paths,
231
+ contentLength: markdownContent.length,
232
+ isHidden: !userInvocable,
233
+ progressMessage: "running",
234
+ source,
235
+ loadedFrom,
236
+ hooks,
237
+ skillRoot: baseDir,
238
+ async getPromptForCommand(args, ctx) {
239
+ let finalContent = markdownContent;
240
+
241
+ // Add base directory prefix if available
242
+ if (baseDir) {
243
+ finalContent = `Base directory for this skill: ${baseDir}\n\n${finalContent}`;
244
+ }
245
+
246
+ // Substitute {{skill.args}} and <arg> placeholders
247
+ finalContent = substituteArguments(finalContent, args, true, argumentNames);
248
+
249
+ // Substitute environment variables
250
+ finalContent = substituteEnvVariables(
251
+ finalContent,
252
+ ctx.env,
253
+ undefined, // sessionId
254
+ baseDir
255
+ );
256
+
257
+ // Execute shell commands (!`...` syntax)
258
+ // In production, this would actually execute the commands
259
+ // For now, we log what would be executed
260
+ finalContent = finalContent.replace(/!`([^`]+)`/g, (_, cmd) => {
261
+ console.log(`[skill:${skillName}] Shell: ${cmd}`);
262
+ return `[shell output: ${cmd}]`;
263
+ });
264
+
265
+ return [{ type: "text" as const, text: finalContent }];
266
+ },
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Load a single skill from a file path
272
+ */
273
+ async function loadSkillFromFile(
274
+ filePath: string,
275
+ source: SkillSource,
276
+ loadedFrom: SkillLoadedFrom
277
+ ): Promise<SkillWithPath | null> {
278
+ try {
279
+ const content = await readFile(filePath, "utf-8");
280
+ const skillDir = path.dirname(filePath);
281
+ const skillName = path.basename(skillDir); // Directory name is the skill name
282
+
283
+ const { frontmatter, body } = parseFrontmatter(content);
284
+ const fields = parseFrontmatterFields(frontmatter, body, skillName);
285
+
286
+ // Parse paths for conditional skills
287
+ const paths = fields.paths;
288
+
289
+ const skill = createSkillCommand({
290
+ skillName,
291
+ displayName: fields.name,
292
+ description: Array.isArray(fields.description)
293
+ ? fields.description.join("\n")
294
+ : fields.description ?? "",
295
+ hasUserSpecifiedDescription: !!frontmatter.description,
296
+ markdownContent: body,
297
+ allowedTools: fields.allowed_tools ?? [],
298
+ argumentHint: fields.argument_hint,
299
+ argumentNames: parseArgumentNames(fields.arguments),
300
+ whenToUse: fields.when_to_use,
301
+ version: fields.version,
302
+ model: fields.model,
303
+ disableModelInvocation: fields.disable_model_invocation ?? false,
304
+ userInvocable: fields.user_invocable ?? true,
305
+ source,
306
+ baseDir: skillDir,
307
+ loadedFrom,
308
+ hooks: fields.hooks,
309
+ executionContext: fields.context,
310
+ agent: fields.agent,
311
+ paths,
312
+ effort: fields.effort,
313
+ });
314
+
315
+ return { skill, filePath };
316
+ } catch (error) {
317
+ console.error(`Failed to load skill from ${filePath}:`, error);
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Load all skills from a directory
324
+ */
325
+ async function loadSkillsFromSkillsDir(
326
+ basePath: string,
327
+ source: SkillSource
328
+ ): Promise<SkillWithPath[]> {
329
+ if (!(await isDirectory(basePath))) {
330
+ return [];
331
+ }
332
+
333
+ const skillDirs = await findSkillDirs(basePath);
334
+ const skills: SkillWithPath[] = [];
335
+
336
+ for (const dir of skillDirs) {
337
+ const skillFile = await findSkillFile(dir);
338
+ if (skillFile) {
339
+ const skillWithPath = await loadSkillFromFile(skillFile, source, "skills");
340
+ if (skillWithPath) {
341
+ skills.push(skillWithPath);
342
+ }
343
+ }
344
+ }
345
+
346
+ return skills;
347
+ }
348
+
349
+ /**
350
+ * Check if a path pattern matches a file path
351
+ * Supports gitignore-style patterns
352
+ */
353
+ function matchesPathPattern(pattern: string, filePath: string): boolean {
354
+ // Simple glob matching - support ** and * patterns
355
+ const regexPattern = pattern
356
+ .replace(/\./g, "\\.")
357
+ .replace(/\*\*/g, "{{DOUBLE_STAR}}")
358
+ .replace(/\*/g, "[^/]*")
359
+ .replace(/\{\{DOUBLE_STAR\}\}/g, ".*")
360
+ .replace(/\?/g, ".");
361
+
362
+ try {
363
+ const regex = new RegExp(`^${regexPattern}(/.*)?$`);
364
+ return regex.test(filePath);
365
+ } catch {
366
+ return false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Activate conditional skills for matching file paths
372
+ */
373
+ export function activateConditionalSkillsForPaths(
374
+ filePaths: string[],
375
+ cwd: string
376
+ ): string[] {
377
+ const activated: string[] = [];
378
+
379
+ for (const [name, skill] of skillState.conditional) {
380
+ if (!skill.paths || skill.paths.length === 0) continue;
381
+
382
+ for (const filePath of filePaths) {
383
+ // Get relative path
384
+ const relativePath = path.isAbsolute(filePath)
385
+ ? path.relative(cwd, filePath)
386
+ : filePath;
387
+
388
+ // Check if any path pattern matches
389
+ const matches = skill.paths.some((pattern) =>
390
+ matchesPathPattern(pattern, relativePath)
391
+ );
392
+
393
+ if (matches) {
394
+ skillState.dynamic.set(name, skill);
395
+ skillState.activatedConditional.add(name);
396
+ skillState.conditional.delete(name);
397
+ activated.push(name);
398
+ console.log(`[skills] Activated conditional skill '${name}' for ${relativePath}`);
399
+ break;
400
+ }
401
+ }
402
+ }
403
+
404
+ if (activated.length > 0) {
405
+ notifyListeners();
406
+ }
407
+
408
+ return activated;
409
+ }
410
+
411
+ /**
412
+ * Discover skill directories for file paths
413
+ */
414
+ export async function discoverSkillDirsForPaths(
415
+ filePaths: string[],
416
+ cwd: string
417
+ ): Promise<string[]> {
418
+ const newDirs: string[] = [];
419
+
420
+ for (const filePath of filePaths) {
421
+ // Walk up from file to cwd
422
+ let currentDir = path.dirname(filePath);
423
+ const resolvedCwd = cwd.endsWith(path.sep) ? cwd.slice(0, -1) : cwd;
424
+
425
+ while (currentDir.startsWith(resolvedCwd + path.sep) || currentDir === resolvedCwd) {
426
+ const skillsDir = path.join(currentDir, ".claude", "skills");
427
+
428
+ if (!skillState.discoveredDirs.has(skillsDir) && await isDirectory(skillsDir)) {
429
+ skillState.discoveredDirs.add(skillsDir);
430
+ newDirs.push(skillsDir);
431
+ }
432
+
433
+ const parent = path.dirname(currentDir);
434
+ if (parent === currentDir) break;
435
+ currentDir = parent;
436
+ }
437
+ }
438
+
439
+ return newDirs;
440
+ }
441
+
442
+ /**
443
+ * Add discovered skill directories
444
+ */
445
+ export async function addSkillDirectories(dirs: string[]): Promise<void> {
446
+ if (dirs.length === 0) return;
447
+
448
+ for (const dir of dirs) {
449
+ const skills = await loadSkillsFromSkillsDir(dir, "projectSettings");
450
+ for (const { skill } of skills) {
451
+ if (skill.type === "prompt") {
452
+ skillState.dynamic.set(skill.name, skill);
453
+ }
454
+ }
455
+ }
456
+
457
+ if (dirs.length > 0) {
458
+ notifyListeners();
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Load all skills from configured directories
464
+ */
465
+ export async function loadAllSkills(cwd?: string): Promise<SkillCommand[]> {
466
+ const seenPaths = new Set<string>();
467
+ const allSkills: SkillCommand[] = [];
468
+
469
+ // Load from user settings directory (~/.config/koi/skills)
470
+ const userSkills = await loadSkillsFromSkillsDir(USER_SKILLS_DIR, "userSettings");
471
+ for (const { skill, filePath } of userSkills) {
472
+ const resolvedPath = await realPath(filePath);
473
+ if (!seenPaths.has(resolvedPath)) {
474
+ seenPaths.add(resolvedPath);
475
+ allSkills.push(skill);
476
+
477
+ // Separate conditional and unconditional skills
478
+ if (skill.paths && skill.paths.length > 0) {
479
+ skillState.conditional.set(skill.name, skill);
480
+ } else {
481
+ skillState.unconditional.set(skill.name, skill);
482
+ }
483
+ }
484
+ }
485
+
486
+ // Load from user Claude skills directory (~/.claude/skills)
487
+ const userClaudeSkills = await loadSkillsFromSkillsDir(USER_CLAUDE_SKILLS_DIR, "userSettings");
488
+ for (const { skill, filePath } of userClaudeSkills) {
489
+ const resolvedPath = await realPath(filePath);
490
+ if (!seenPaths.has(resolvedPath)) {
491
+ seenPaths.add(resolvedPath);
492
+ allSkills.push(skill);
493
+
494
+ if (skill.paths && skill.paths.length > 0) {
495
+ skillState.conditional.set(skill.name, skill);
496
+ } else {
497
+ skillState.unconditional.set(skill.name, skill);
498
+ }
499
+ }
500
+ }
501
+
502
+ // Load from project settings directory
503
+ if (cwd) {
504
+ const projectSkillsDir = path.join(cwd, ".claude", "skills");
505
+ const projectSkills = await loadSkillsFromSkillsDir(projectSkillsDir, "projectSettings");
506
+ for (const { skill, filePath } of projectSkills) {
507
+ const resolvedPath = await realPath(filePath);
508
+ if (!seenPaths.has(resolvedPath)) {
509
+ seenPaths.add(resolvedPath);
510
+ allSkills.push(skill);
511
+
512
+ if (skill.paths && skill.paths.length > 0) {
513
+ skillState.conditional.set(skill.name, skill);
514
+ } else {
515
+ skillState.unconditional.set(skill.name, skill);
516
+ }
517
+ }
518
+ }
519
+ }
520
+
521
+ // Load bundled skills
522
+ const bundledSkillsList = loadBundledSkillsInternal();
523
+ for (const skill of bundledSkillsList) {
524
+ if (!seenPaths.has(skill.name)) {
525
+ allSkills.push(skill);
526
+ skillState.unconditional.set(skill.name, skill);
527
+ }
528
+ }
529
+
530
+ skillsLoaded = true;
531
+ notifyListeners();
532
+
533
+ return allSkills;
534
+ }
535
+
536
+ /**
537
+ * Notify listeners that skills have been loaded
538
+ */
539
+ function notifyListeners(): void {
540
+ for (const listener of loadListeners) {
541
+ try {
542
+ listener();
543
+ } catch (error) {
544
+ console.error("[skills] Listener error:", error);
545
+ }
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Get all registered skills (unconditional + activated conditional + dynamic)
551
+ */
552
+ export function getAllSkills(): SkillCommand[] {
553
+ const all = [
554
+ ...skillState.unconditional.values(),
555
+ ...skillState.conditional.values(),
556
+ ...skillState.dynamic.values(),
557
+ ];
558
+
559
+ // Deduplicate by name
560
+ const seen = new Set<string>();
561
+ return all.filter((skill) => {
562
+ if (seen.has(skill.name)) return false;
563
+ seen.add(skill.name);
564
+ return true;
565
+ });
566
+ }
567
+
568
+ /**
569
+ * Get all active skills (unconditional + activated conditional + dynamic)
570
+ */
571
+ export function getActiveSkills(): SkillCommand[] {
572
+ const all = [
573
+ ...skillState.unconditional.values(),
574
+ ...Array.from(skillState.conditional.values()).filter(
575
+ (s) => skillState.activatedConditional.has(s.name)
576
+ ),
577
+ ...skillState.dynamic.values(),
578
+ ];
579
+
580
+ // Deduplicate by name
581
+ const seen = new Set<string>();
582
+ return all.filter((skill) => {
583
+ if (seen.has(skill.name)) return false;
584
+ seen.add(skill.name);
585
+ return true;
586
+ });
587
+ }
588
+
589
+ /**
590
+ * Get a skill by name
591
+ */
592
+ export function getSkillByName(name: string): SkillCommand | undefined {
593
+ return (
594
+ skillState.unconditional.get(name.toLowerCase()) ||
595
+ skillState.conditional.get(name.toLowerCase()) ||
596
+ skillState.dynamic.get(name.toLowerCase())
597
+ );
598
+ }
599
+
600
+ /**
601
+ * Check if a skill name exists
602
+ */
603
+ export function hasSkill(name: string): boolean {
604
+ return getSkillByName(name) !== undefined;
605
+ }
606
+
607
+ /**
608
+ * Subscribe to skill loading events
609
+ */
610
+ export function onSkillsLoaded(callback: () => void): () => void {
611
+ loadListeners.push(callback);
612
+ return () => {
613
+ loadListeners = loadListeners.filter((l) => l !== callback);
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Get dynamic skills
619
+ */
620
+ export function getDynamicSkills(): SkillCommand[] {
621
+ return Array.from(skillState.dynamic.values());
622
+ }
623
+
624
+ /**
625
+ * Get conditional skills count
626
+ */
627
+ export function getConditionalSkillCount(): number {
628
+ return skillState.conditional.size;
629
+ }
630
+
631
+ // Bundled skills storage
632
+ const bundledSkillsDefs: BundledSkillDefinition[] = [];
633
+
634
+ /**
635
+ * Register a bundled skill
636
+ */
637
+ export function registerBundledSkill(definition: BundledSkillDefinition): void {
638
+ bundledSkillsDefs.push(definition);
639
+ }
640
+
641
+ /**
642
+ * Get all bundled skill definitions
643
+ */
644
+ export function getBundledSkillDefinitions(): BundledSkillDefinition[] {
645
+ return [...bundledSkillsDefs];
646
+ }
647
+
648
+ /**
649
+ * Internal function to convert bundled skill definitions to SkillCommands
650
+ */
651
+ function loadBundledSkillsInternal(): SkillCommand[] {
652
+ return bundledSkillsDefs
653
+ .filter((def) => !def.isEnabled || def.isEnabled())
654
+ .map((def) => ({
655
+ type: "prompt" as const,
656
+ name: def.name,
657
+ description: def.description,
658
+ hasUserSpecifiedDescription: false,
659
+ allowedTools: def.allowedTools ?? [],
660
+ argumentHint: def.argumentHint,
661
+ argNames: parseArgumentNames(def.argumentHint),
662
+ whenToUse: def.whenToUse,
663
+ model: def.model,
664
+ disableModelInvocation: def.disableModelInvocation ?? false,
665
+ userInvocable: def.userInvocable ?? true,
666
+ context: def.context,
667
+ agent: def.agent,
668
+ contentLength: 0,
669
+ isHidden: !(def.userInvocable ?? true),
670
+ progressMessage: "running",
671
+ source: "bundled" as SkillSource,
672
+ loadedFrom: "bundled" as SkillLoadedFrom,
673
+ hooks: def.hooks,
674
+ skillRoot: undefined,
675
+ getPromptForCommand: def.getPromptForCommand,
676
+ }));
677
+ }
678
+
679
+ /**
680
+ * Reset skill registry (useful for testing)
681
+ */
682
+ export function resetSkillRegistry(): void {
683
+ skillState.unconditional.clear();
684
+ skillState.conditional.clear();
685
+ skillState.activatedConditional.clear();
686
+ skillState.dynamic.clear();
687
+ skillState.discoveredDirs.clear();
688
+ skillsLoaded = false;
689
+ }
690
+
691
+ /**
692
+ * Get skills grouped by source
693
+ */
694
+ export function getSkillsBySource(): Map<SkillSource, SkillCommand[]> {
695
+ const bySource = new Map<SkillSource, SkillCommand[]>();
696
+ const allSkills = getAllSkills();
697
+
698
+ for (const skill of allSkills) {
699
+ const existing = bySource.get(skill.source) ?? [];
700
+ existing.push(skill);
701
+ bySource.set(skill.source, existing);
702
+ }
703
+
704
+ return bySource;
705
+ }
706
+
707
+ /**
708
+ * Export bundled module
709
+ */
710
+ export * from "./bundled.js";