@oh-my-pi/pi-coding-agent 1.337.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 (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,437 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
2
+ import { minimatch } from "minimatch";
3
+ import { homedir } from "os";
4
+ import { basename, dirname, join, resolve } from "path";
5
+ import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
6
+ import type { SkillsSettings } from "./settings-manager.js";
7
+
8
+ /**
9
+ * Standard frontmatter fields per Agent Skills spec.
10
+ * See: https://agentskills.io/specification#frontmatter-required
11
+ */
12
+ const ALLOWED_FRONTMATTER_FIELDS = new Set([
13
+ "name",
14
+ "description",
15
+ "license",
16
+ "compatibility",
17
+ "metadata",
18
+ "allowed-tools",
19
+ ]);
20
+
21
+ /** Max name length per spec */
22
+ const MAX_NAME_LENGTH = 64;
23
+
24
+ /** Max description length per spec */
25
+ const MAX_DESCRIPTION_LENGTH = 1024;
26
+
27
+ export interface SkillFrontmatter {
28
+ name?: string;
29
+ description?: string;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ export interface Skill {
34
+ name: string;
35
+ description: string;
36
+ filePath: string;
37
+ baseDir: string;
38
+ source: string;
39
+ }
40
+
41
+ export interface SkillWarning {
42
+ skillPath: string;
43
+ message: string;
44
+ }
45
+
46
+ export interface LoadSkillsResult {
47
+ skills: Skill[];
48
+ warnings: SkillWarning[];
49
+ }
50
+
51
+ type SkillFormat = "recursive" | "claude";
52
+
53
+ function stripQuotes(value: string): string {
54
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
55
+ return value.slice(1, -1);
56
+ }
57
+ return value;
58
+ }
59
+
60
+ function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string; allKeys: string[] } {
61
+ const frontmatter: SkillFrontmatter = {};
62
+ const allKeys: string[] = [];
63
+
64
+ const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
65
+
66
+ if (!normalizedContent.startsWith("---")) {
67
+ return { frontmatter, body: normalizedContent, allKeys };
68
+ }
69
+
70
+ const endIndex = normalizedContent.indexOf("\n---", 3);
71
+ if (endIndex === -1) {
72
+ return { frontmatter, body: normalizedContent, allKeys };
73
+ }
74
+
75
+ const frontmatterBlock = normalizedContent.slice(4, endIndex);
76
+ const body = normalizedContent.slice(endIndex + 4).trim();
77
+
78
+ for (const line of frontmatterBlock.split("\n")) {
79
+ const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
80
+ if (match) {
81
+ const key = match[1];
82
+ const value = stripQuotes(match[2].trim());
83
+ allKeys.push(key);
84
+ if (key === "name") {
85
+ frontmatter.name = value;
86
+ } else if (key === "description") {
87
+ frontmatter.description = value;
88
+ }
89
+ }
90
+ }
91
+
92
+ return { frontmatter, body, allKeys };
93
+ }
94
+
95
+ /**
96
+ * Validate skill name per Agent Skills spec.
97
+ * Returns array of validation error messages (empty if valid).
98
+ */
99
+ function validateName(name: string, parentDirName: string): string[] {
100
+ const errors: string[] = [];
101
+
102
+ if (name !== parentDirName) {
103
+ errors.push(`name "${name}" does not match parent directory "${parentDirName}"`);
104
+ }
105
+
106
+ if (name.length > MAX_NAME_LENGTH) {
107
+ errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
108
+ }
109
+
110
+ if (!/^[a-z0-9-]+$/.test(name)) {
111
+ errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);
112
+ }
113
+
114
+ if (name.startsWith("-") || name.endsWith("-")) {
115
+ errors.push(`name must not start or end with a hyphen`);
116
+ }
117
+
118
+ if (name.includes("--")) {
119
+ errors.push(`name must not contain consecutive hyphens`);
120
+ }
121
+
122
+ return errors;
123
+ }
124
+
125
+ /**
126
+ * Validate description per Agent Skills spec.
127
+ */
128
+ function validateDescription(description: string | undefined): string[] {
129
+ const errors: string[] = [];
130
+
131
+ if (!description || description.trim() === "") {
132
+ errors.push(`description is required`);
133
+ } else if (description.length > MAX_DESCRIPTION_LENGTH) {
134
+ errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
135
+ }
136
+
137
+ return errors;
138
+ }
139
+
140
+ /**
141
+ * Check for unknown frontmatter fields.
142
+ */
143
+ function validateFrontmatterFields(keys: string[]): string[] {
144
+ const errors: string[] = [];
145
+ for (const key of keys) {
146
+ if (!ALLOWED_FRONTMATTER_FIELDS.has(key)) {
147
+ errors.push(`unknown frontmatter field "${key}"`);
148
+ }
149
+ }
150
+ return errors;
151
+ }
152
+
153
+ export interface LoadSkillsFromDirOptions {
154
+ /** Directory to scan for skills */
155
+ dir: string;
156
+ /** Source identifier for these skills */
157
+ source: string;
158
+ }
159
+
160
+ /**
161
+ * Load skills from a directory recursively.
162
+ * Skills are directories containing a SKILL.md file with frontmatter including a description.
163
+ */
164
+ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
165
+ const { dir, source } = options;
166
+ return loadSkillsFromDirInternal(dir, source, "recursive");
167
+ }
168
+
169
+ function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFormat): LoadSkillsResult {
170
+ const skills: Skill[] = [];
171
+ const warnings: SkillWarning[] = [];
172
+
173
+ if (!existsSync(dir)) {
174
+ return { skills, warnings };
175
+ }
176
+
177
+ try {
178
+ const entries = readdirSync(dir, { withFileTypes: true });
179
+
180
+ for (const entry of entries) {
181
+ if (entry.name.startsWith(".")) {
182
+ continue;
183
+ }
184
+
185
+ // Skip node_modules to avoid scanning dependencies
186
+ if (entry.name === "node_modules") {
187
+ continue;
188
+ }
189
+
190
+ const fullPath = join(dir, entry.name);
191
+
192
+ // For symlinks, check if they point to a directory and follow them
193
+ let isDirectory = entry.isDirectory();
194
+ let isFile = entry.isFile();
195
+ if (entry.isSymbolicLink()) {
196
+ try {
197
+ const stats = statSync(fullPath);
198
+ isDirectory = stats.isDirectory();
199
+ isFile = stats.isFile();
200
+ } catch {
201
+ // Broken symlink, skip it
202
+ continue;
203
+ }
204
+ }
205
+
206
+ if (format === "recursive") {
207
+ // Recursive format: scan directories, look for SKILL.md files
208
+ if (isDirectory) {
209
+ const subResult = loadSkillsFromDirInternal(fullPath, source, format);
210
+ skills.push(...subResult.skills);
211
+ warnings.push(...subResult.warnings);
212
+ } else if (isFile && entry.name === "SKILL.md") {
213
+ const result = loadSkillFromFile(fullPath, source);
214
+ if (result.skill) {
215
+ skills.push(result.skill);
216
+ }
217
+ warnings.push(...result.warnings);
218
+ }
219
+ } else if (format === "claude") {
220
+ // Claude format: only one level deep, each directory must contain SKILL.md
221
+ if (!isDirectory) {
222
+ continue;
223
+ }
224
+
225
+ const skillFile = join(fullPath, "SKILL.md");
226
+ if (!existsSync(skillFile)) {
227
+ continue;
228
+ }
229
+
230
+ const result = loadSkillFromFile(skillFile, source);
231
+ if (result.skill) {
232
+ skills.push(result.skill);
233
+ }
234
+ warnings.push(...result.warnings);
235
+ }
236
+ }
237
+ } catch {}
238
+
239
+ return { skills, warnings };
240
+ }
241
+
242
+ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | null; warnings: SkillWarning[] } {
243
+ const warnings: SkillWarning[] = [];
244
+
245
+ try {
246
+ const rawContent = readFileSync(filePath, "utf-8");
247
+ const { frontmatter, allKeys } = parseFrontmatter(rawContent);
248
+ const skillDir = dirname(filePath);
249
+ const parentDirName = basename(skillDir);
250
+
251
+ // Validate frontmatter fields
252
+ const fieldErrors = validateFrontmatterFields(allKeys);
253
+ for (const error of fieldErrors) {
254
+ warnings.push({ skillPath: filePath, message: error });
255
+ }
256
+
257
+ // Validate description
258
+ const descErrors = validateDescription(frontmatter.description);
259
+ for (const error of descErrors) {
260
+ warnings.push({ skillPath: filePath, message: error });
261
+ }
262
+
263
+ // Use name from frontmatter, or fall back to parent directory name
264
+ const name = frontmatter.name || parentDirName;
265
+
266
+ // Validate name
267
+ const nameErrors = validateName(name, parentDirName);
268
+ for (const error of nameErrors) {
269
+ warnings.push({ skillPath: filePath, message: error });
270
+ }
271
+
272
+ // Still load the skill even with warnings (unless description is completely missing)
273
+ if (!frontmatter.description || frontmatter.description.trim() === "") {
274
+ return { skill: null, warnings };
275
+ }
276
+
277
+ return {
278
+ skill: {
279
+ name,
280
+ description: frontmatter.description,
281
+ filePath,
282
+ baseDir: skillDir,
283
+ source,
284
+ },
285
+ warnings,
286
+ };
287
+ } catch {
288
+ return { skill: null, warnings };
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Format skills for inclusion in a system prompt.
294
+ * Uses XML format per Agent Skills standard.
295
+ * See: https://agentskills.io/integrate-skills
296
+ */
297
+ export function formatSkillsForPrompt(skills: Skill[]): string {
298
+ if (skills.length === 0) {
299
+ return "";
300
+ }
301
+
302
+ const lines = [
303
+ "\n\nThe following skills provide specialized instructions for specific tasks.",
304
+ "Use the read tool to load a skill's file when the task matches its description.",
305
+ "",
306
+ "<available_skills>",
307
+ ];
308
+
309
+ for (const skill of skills) {
310
+ lines.push(" <skill>");
311
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
312
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
313
+ lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
314
+ lines.push(" </skill>");
315
+ }
316
+
317
+ lines.push("</available_skills>");
318
+
319
+ return lines.join("\n");
320
+ }
321
+
322
+ function escapeXml(str: string): string {
323
+ return str
324
+ .replace(/&/g, "&amp;")
325
+ .replace(/</g, "&lt;")
326
+ .replace(/>/g, "&gt;")
327
+ .replace(/"/g, "&quot;")
328
+ .replace(/'/g, "&apos;");
329
+ }
330
+
331
+ export interface LoadSkillsOptions extends SkillsSettings {
332
+ /** Working directory for project-local skills. Default: process.cwd() */
333
+ cwd?: string;
334
+ /** Agent config directory for global skills. Default: ~/.pi/agent */
335
+ agentDir?: string;
336
+ }
337
+
338
+ /**
339
+ * Load skills from all configured locations.
340
+ * Returns skills and any validation warnings.
341
+ */
342
+ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
343
+ const {
344
+ cwd = process.cwd(),
345
+ agentDir,
346
+ enableCodexUser = true,
347
+ enableClaudeUser = true,
348
+ enableClaudeProject = true,
349
+ enablePiUser = true,
350
+ enablePiProject = true,
351
+ customDirectories = [],
352
+ ignoredSkills = [],
353
+ includeSkills = [],
354
+ } = options;
355
+
356
+ // Resolve agentDir - if not provided, use default from config
357
+ const resolvedAgentDir = agentDir ?? getAgentDir();
358
+
359
+ const skillMap = new Map<string, Skill>();
360
+ const realPathSet = new Set<string>();
361
+ const allWarnings: SkillWarning[] = [];
362
+ const collisionWarnings: SkillWarning[] = [];
363
+
364
+ // Check if skill name matches any of the include patterns
365
+ function matchesIncludePatterns(name: string): boolean {
366
+ if (includeSkills.length === 0) return true; // No filter = include all
367
+ return includeSkills.some((pattern) => minimatch(name, pattern));
368
+ }
369
+
370
+ // Check if skill name matches any of the ignore patterns
371
+ function matchesIgnorePatterns(name: string): boolean {
372
+ if (ignoredSkills.length === 0) return false;
373
+ return ignoredSkills.some((pattern) => minimatch(name, pattern));
374
+ }
375
+
376
+ function addSkills(result: LoadSkillsResult) {
377
+ allWarnings.push(...result.warnings);
378
+ for (const skill of result.skills) {
379
+ // Apply ignore filter (glob patterns) - takes precedence over include
380
+ if (matchesIgnorePatterns(skill.name)) {
381
+ continue;
382
+ }
383
+ // Apply include filter (glob patterns)
384
+ if (!matchesIncludePatterns(skill.name)) {
385
+ continue;
386
+ }
387
+
388
+ // Resolve symlinks to detect duplicate files
389
+ let realPath: string;
390
+ try {
391
+ realPath = realpathSync(skill.filePath);
392
+ } catch {
393
+ realPath = skill.filePath;
394
+ }
395
+
396
+ // Skip silently if we've already loaded this exact file (via symlink)
397
+ if (realPathSet.has(realPath)) {
398
+ continue;
399
+ }
400
+
401
+ const existing = skillMap.get(skill.name);
402
+ if (existing) {
403
+ collisionWarnings.push({
404
+ skillPath: skill.filePath,
405
+ message: `name collision: "${skill.name}" already loaded from ${existing.filePath}, skipping this one`,
406
+ });
407
+ } else {
408
+ skillMap.set(skill.name, skill);
409
+ realPathSet.add(realPath);
410
+ }
411
+ }
412
+ }
413
+
414
+ if (enableCodexUser) {
415
+ addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive"));
416
+ }
417
+ if (enableClaudeUser) {
418
+ addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
419
+ }
420
+ if (enableClaudeProject) {
421
+ addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude"));
422
+ }
423
+ if (enablePiUser) {
424
+ addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive"));
425
+ }
426
+ if (enablePiProject) {
427
+ addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive"));
428
+ }
429
+ for (const customDir of customDirectories) {
430
+ addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
431
+ }
432
+
433
+ return {
434
+ skills: Array.from(skillMap.values()),
435
+ warnings: [...allWarnings, ...collisionWarnings],
436
+ };
437
+ }
@@ -0,0 +1,248 @@
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { CONFIG_DIR_NAME, getCommandsDir } from "../config.js";
4
+
5
+ /**
6
+ * Represents a custom slash command loaded from a file
7
+ */
8
+ export interface FileSlashCommand {
9
+ name: string;
10
+ description: string;
11
+ content: string;
12
+ source: string; // e.g., "(user)", "(project)", "(project:frontend)"
13
+ }
14
+
15
+ /**
16
+ * Parse YAML frontmatter from markdown content
17
+ * Returns { frontmatter, content } where content has frontmatter stripped
18
+ */
19
+ function parseFrontmatter(content: string): { frontmatter: Record<string, string>; content: string } {
20
+ const frontmatter: Record<string, string> = {};
21
+
22
+ if (!content.startsWith("---")) {
23
+ return { frontmatter, content };
24
+ }
25
+
26
+ const endIndex = content.indexOf("\n---", 3);
27
+ if (endIndex === -1) {
28
+ return { frontmatter, content };
29
+ }
30
+
31
+ const frontmatterBlock = content.slice(4, endIndex);
32
+ const remainingContent = content.slice(endIndex + 4).trim();
33
+
34
+ // Simple YAML parsing - just key: value pairs
35
+ for (const line of frontmatterBlock.split("\n")) {
36
+ const match = line.match(/^(\w+):\s*(.*)$/);
37
+ if (match) {
38
+ frontmatter[match[1]] = match[2].trim();
39
+ }
40
+ }
41
+
42
+ return { frontmatter, content: remainingContent };
43
+ }
44
+
45
+ /**
46
+ * Parse command arguments respecting quoted strings (bash-style)
47
+ * Returns array of arguments
48
+ */
49
+ export function parseCommandArgs(argsString: string): string[] {
50
+ const args: string[] = [];
51
+ let current = "";
52
+ let inQuote: string | null = null;
53
+
54
+ for (let i = 0; i < argsString.length; i++) {
55
+ const char = argsString[i];
56
+
57
+ if (inQuote) {
58
+ if (char === inQuote) {
59
+ inQuote = null;
60
+ } else {
61
+ current += char;
62
+ }
63
+ } else if (char === '"' || char === "'") {
64
+ inQuote = char;
65
+ } else if (char === " " || char === "\t") {
66
+ if (current) {
67
+ args.push(current);
68
+ current = "";
69
+ }
70
+ } else {
71
+ current += char;
72
+ }
73
+ }
74
+
75
+ if (current) {
76
+ args.push(current);
77
+ }
78
+
79
+ return args;
80
+ }
81
+
82
+ /**
83
+ * Substitute argument placeholders in command content
84
+ * Supports $1, $2, ... for positional args and $@ for all args
85
+ */
86
+ export function substituteArgs(content: string, args: string[]): string {
87
+ let result = content;
88
+
89
+ // Replace $@ with all args joined
90
+ result = result.replace(/\$@/g, args.join(" "));
91
+
92
+ // Replace $1, $2, etc. with positional args
93
+ result = result.replace(/\$(\d+)/g, (_, num) => {
94
+ const index = parseInt(num, 10) - 1;
95
+ return args[index] ?? "";
96
+ });
97
+
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands
103
+ */
104
+ function loadCommandsFromDir(
105
+ dir: string,
106
+ source: "builtin" | "user" | "project",
107
+ subdir: string = "",
108
+ ): FileSlashCommand[] {
109
+ const commands: FileSlashCommand[] = [];
110
+
111
+ if (!existsSync(dir)) {
112
+ return commands;
113
+ }
114
+
115
+ try {
116
+ const entries = readdirSync(dir, { withFileTypes: true });
117
+
118
+ for (const entry of entries) {
119
+ const fullPath = join(dir, entry.name);
120
+
121
+ if (entry.isDirectory()) {
122
+ // Recurse into subdirectory
123
+ const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
124
+ commands.push(...loadCommandsFromDir(fullPath, source, newSubdir));
125
+ } else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) {
126
+ try {
127
+ const rawContent = readFileSync(fullPath, "utf-8");
128
+ const { frontmatter, content } = parseFrontmatter(rawContent);
129
+
130
+ const name = entry.name.slice(0, -3); // Remove .md extension
131
+
132
+ // Build source string
133
+ let sourceStr: string;
134
+ if (source === "builtin") {
135
+ sourceStr = subdir ? `(builtin:${subdir})` : "(builtin)";
136
+ } else if (source === "user") {
137
+ sourceStr = subdir ? `(user:${subdir})` : "(user)";
138
+ } else {
139
+ sourceStr = subdir ? `(project:${subdir})` : "(project)";
140
+ }
141
+
142
+ // Get description from frontmatter or first non-empty line
143
+ let description = frontmatter.description || "";
144
+ if (!description) {
145
+ const firstLine = content.split("\n").find((line) => line.trim());
146
+ if (firstLine) {
147
+ // Truncate if too long
148
+ description = firstLine.slice(0, 60);
149
+ if (firstLine.length > 60) description += "...";
150
+ }
151
+ }
152
+
153
+ // Append source to description
154
+ description = description ? `${description} ${sourceStr}` : sourceStr;
155
+
156
+ commands.push({
157
+ name,
158
+ description,
159
+ content,
160
+ source: sourceStr,
161
+ });
162
+ } catch (_error) {
163
+ // Silently skip files that can't be read
164
+ }
165
+ }
166
+ }
167
+ } catch (_error) {
168
+ // Silently skip directories that can't be read
169
+ }
170
+
171
+ return commands;
172
+ }
173
+
174
+ export interface LoadSlashCommandsOptions {
175
+ /** Working directory for project-local commands. Default: process.cwd() */
176
+ cwd?: string;
177
+ /** Agent config directory for global commands. Default: from getCommandsDir() */
178
+ agentDir?: string;
179
+ }
180
+
181
+ /**
182
+ * Load all custom slash commands from:
183
+ * 1. Builtin: package commands/
184
+ * 2. Global: agentDir/commands/
185
+ * 3. Project: cwd/{CONFIG_DIR_NAME}/commands/
186
+ */
187
+ export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
188
+ const resolvedCwd = options.cwd ?? process.cwd();
189
+ const resolvedAgentDir = options.agentDir ?? getCommandsDir();
190
+
191
+ const commands: FileSlashCommand[] = [];
192
+ const seenNames = new Set<string>();
193
+
194
+ // 1. Builtin commands (from package)
195
+ const builtinDir = join(import.meta.dir, "../commands");
196
+ if (existsSync(builtinDir)) {
197
+ const builtinCommands = loadCommandsFromDir(builtinDir, "builtin");
198
+ for (const cmd of builtinCommands) {
199
+ if (!seenNames.has(cmd.name)) {
200
+ commands.push(cmd);
201
+ seenNames.add(cmd.name);
202
+ }
203
+ }
204
+ }
205
+
206
+ // 2. Load global commands from agentDir/commands/
207
+ // Note: if agentDir is provided, it should be the agent dir, not the commands dir
208
+ const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir;
209
+ const globalCommands = loadCommandsFromDir(globalCommandsDir, "user");
210
+ for (const cmd of globalCommands) {
211
+ if (!seenNames.has(cmd.name)) {
212
+ commands.push(cmd);
213
+ seenNames.add(cmd.name);
214
+ }
215
+ }
216
+
217
+ // 3. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/
218
+ const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
219
+ const projectCommands = loadCommandsFromDir(projectCommandsDir, "project");
220
+ for (const cmd of projectCommands) {
221
+ if (!seenNames.has(cmd.name)) {
222
+ commands.push(cmd);
223
+ seenNames.add(cmd.name);
224
+ }
225
+ }
226
+
227
+ return commands;
228
+ }
229
+
230
+ /**
231
+ * Expand a slash command if it matches a file-based command.
232
+ * Returns the expanded content or the original text if not a slash command.
233
+ */
234
+ export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string {
235
+ if (!text.startsWith("/")) return text;
236
+
237
+ const spaceIndex = text.indexOf(" ");
238
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
239
+ const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
240
+
241
+ const fileCommand = fileCommands.find((cmd) => cmd.name === commandName);
242
+ if (fileCommand) {
243
+ const args = parseCommandArgs(argsString);
244
+ return substituteArgs(fileCommand.content, args);
245
+ }
246
+
247
+ return text;
248
+ }