@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.
- package/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- 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";
|