@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.
- package/CHANGELOG.md +1228 -0
- package/README.md +1041 -0
- package/docs/compaction.md +403 -0
- package/docs/custom-tools.md +541 -0
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +867 -0
- package/docs/rpc.md +1040 -0
- package/docs/sdk.md +994 -0
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +240 -0
- package/docs/skills.md +290 -0
- package/docs/theme.md +637 -0
- package/docs/tree.md +197 -0
- package/docs/tui.md +341 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +124 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/question/index.ts +84 -0
- package/examples/custom-tools/subagent/README.md +172 -0
- package/examples/custom-tools/subagent/agents/planner.md +37 -0
- package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
- package/examples/custom-tools/subagent/agents/scout.md +50 -0
- package/examples/custom-tools/subagent/agents/worker.md +24 -0
- package/examples/custom-tools/subagent/agents.ts +156 -0
- package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
- package/examples/custom-tools/subagent/commands/implement.md +10 -0
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
- package/examples/custom-tools/subagent/index.ts +1002 -0
- package/examples/custom-tools/todo/index.ts +212 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +49 -0
- package/examples/hooks/confirm-destructive.ts +59 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +52 -0
- package/examples/hooks/file-trigger.ts +41 -0
- package/examples/hooks/git-checkpoint.ts +53 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +34 -0
- package/examples/hooks/protected-paths.ts +30 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +44 -0
- package/examples/sdk/04-skills.ts +44 -0
- package/examples/sdk/05-tools.ts +90 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +42 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +95 -0
- package/examples/sdk/README.md +154 -0
- package/package.json +81 -0
- package/src/cli/args.ts +246 -0
- package/src/cli/file-processor.ts +72 -0
- package/src/cli/list-models.ts +104 -0
- package/src/cli/plugin-cli.ts +650 -0
- package/src/cli/session-picker.ts +41 -0
- package/src/cli.ts +10 -0
- package/src/commands/init.md +20 -0
- package/src/config.ts +159 -0
- package/src/core/agent-session.ts +1900 -0
- package/src/core/auth-storage.ts +236 -0
- package/src/core/bash-executor.ts +196 -0
- package/src/core/compaction/branch-summarization.ts +343 -0
- package/src/core/compaction/compaction.ts +742 -0
- package/src/core/compaction/index.ts +7 -0
- package/src/core/compaction/utils.ts +154 -0
- package/src/core/custom-tools/index.ts +21 -0
- package/src/core/custom-tools/loader.ts +248 -0
- package/src/core/custom-tools/types.ts +169 -0
- package/src/core/custom-tools/wrapper.ts +28 -0
- package/src/core/exec.ts +129 -0
- package/src/core/export-html/index.ts +211 -0
- package/src/core/export-html/template.css +781 -0
- package/src/core/export-html/template.html +54 -0
- package/src/core/export-html/template.js +1185 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/loader.ts +312 -0
- package/src/core/hooks/runner.ts +434 -0
- package/src/core/hooks/tool-wrapper.ts +99 -0
- package/src/core/hooks/types.ts +773 -0
- package/src/core/index.ts +52 -0
- package/src/core/mcp/client.ts +158 -0
- package/src/core/mcp/config.ts +154 -0
- package/src/core/mcp/index.ts +45 -0
- package/src/core/mcp/loader.ts +68 -0
- package/src/core/mcp/manager.ts +181 -0
- package/src/core/mcp/tool-bridge.ts +148 -0
- package/src/core/mcp/transports/http.ts +316 -0
- package/src/core/mcp/transports/index.ts +6 -0
- package/src/core/mcp/transports/stdio.ts +252 -0
- package/src/core/mcp/types.ts +220 -0
- package/src/core/messages.ts +189 -0
- package/src/core/model-registry.ts +317 -0
- package/src/core/model-resolver.ts +393 -0
- package/src/core/plugins/doctor.ts +59 -0
- package/src/core/plugins/index.ts +38 -0
- package/src/core/plugins/installer.ts +189 -0
- package/src/core/plugins/loader.ts +338 -0
- package/src/core/plugins/manager.ts +672 -0
- package/src/core/plugins/parser.ts +105 -0
- package/src/core/plugins/paths.ts +32 -0
- package/src/core/plugins/types.ts +190 -0
- package/src/core/sdk.ts +760 -0
- package/src/core/session-manager.ts +1128 -0
- package/src/core/settings-manager.ts +443 -0
- package/src/core/skills.ts +437 -0
- package/src/core/slash-commands.ts +248 -0
- package/src/core/system-prompt.ts +439 -0
- package/src/core/timings.ts +25 -0
- package/src/core/tools/ask.ts +211 -0
- package/src/core/tools/bash-interceptor.ts +120 -0
- package/src/core/tools/bash.ts +250 -0
- package/src/core/tools/context.ts +32 -0
- package/src/core/tools/edit-diff.ts +475 -0
- package/src/core/tools/edit.ts +208 -0
- package/src/core/tools/exa/company.ts +59 -0
- package/src/core/tools/exa/index.ts +64 -0
- package/src/core/tools/exa/linkedin.ts +59 -0
- package/src/core/tools/exa/logger.ts +56 -0
- package/src/core/tools/exa/mcp-client.ts +368 -0
- package/src/core/tools/exa/render.ts +196 -0
- package/src/core/tools/exa/researcher.ts +90 -0
- package/src/core/tools/exa/search.ts +337 -0
- package/src/core/tools/exa/types.ts +168 -0
- package/src/core/tools/exa/websets.ts +248 -0
- package/src/core/tools/find.ts +261 -0
- package/src/core/tools/grep.ts +555 -0
- package/src/core/tools/index.ts +202 -0
- package/src/core/tools/ls.ts +140 -0
- package/src/core/tools/lsp/client.ts +605 -0
- package/src/core/tools/lsp/config.ts +147 -0
- package/src/core/tools/lsp/edits.ts +101 -0
- package/src/core/tools/lsp/index.ts +804 -0
- package/src/core/tools/lsp/render.ts +447 -0
- package/src/core/tools/lsp/rust-analyzer.ts +145 -0
- package/src/core/tools/lsp/types.ts +463 -0
- package/src/core/tools/lsp/utils.ts +486 -0
- package/src/core/tools/notebook.ts +229 -0
- package/src/core/tools/path-utils.ts +61 -0
- package/src/core/tools/read.ts +240 -0
- package/src/core/tools/renderers.ts +540 -0
- package/src/core/tools/task/agents.ts +153 -0
- package/src/core/tools/task/artifacts.ts +114 -0
- package/src/core/tools/task/bundled-agents/browser.md +71 -0
- package/src/core/tools/task/bundled-agents/explore.md +82 -0
- package/src/core/tools/task/bundled-agents/plan.md +54 -0
- package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
- package/src/core/tools/task/bundled-agents/task.md +53 -0
- package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
- package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
- package/src/core/tools/task/bundled-commands/implement.md +11 -0
- package/src/core/tools/task/commands.ts +213 -0
- package/src/core/tools/task/discovery.ts +208 -0
- package/src/core/tools/task/executor.ts +367 -0
- package/src/core/tools/task/index.ts +388 -0
- package/src/core/tools/task/model-resolver.ts +115 -0
- package/src/core/tools/task/parallel.ts +38 -0
- package/src/core/tools/task/render.ts +232 -0
- package/src/core/tools/task/types.ts +99 -0
- package/src/core/tools/truncate.ts +265 -0
- package/src/core/tools/web-fetch.ts +2370 -0
- package/src/core/tools/web-search/auth.ts +193 -0
- package/src/core/tools/web-search/index.ts +537 -0
- package/src/core/tools/web-search/providers/anthropic.ts +198 -0
- package/src/core/tools/web-search/providers/exa.ts +302 -0
- package/src/core/tools/web-search/providers/perplexity.ts +195 -0
- package/src/core/tools/web-search/render.ts +182 -0
- package/src/core/tools/web-search/types.ts +180 -0
- package/src/core/tools/write.ts +99 -0
- package/src/index.ts +176 -0
- package/src/main.ts +464 -0
- package/src/migrations.ts +135 -0
- package/src/modes/index.ts +43 -0
- package/src/modes/interactive/components/armin.ts +382 -0
- package/src/modes/interactive/components/assistant-message.ts +86 -0
- package/src/modes/interactive/components/bash-execution.ts +196 -0
- package/src/modes/interactive/components/bordered-loader.ts +41 -0
- package/src/modes/interactive/components/branch-summary-message.ts +42 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
- package/src/modes/interactive/components/custom-editor.ts +122 -0
- package/src/modes/interactive/components/diff.ts +147 -0
- package/src/modes/interactive/components/dynamic-border.ts +25 -0
- package/src/modes/interactive/components/footer.ts +381 -0
- package/src/modes/interactive/components/hook-editor.ts +117 -0
- package/src/modes/interactive/components/hook-input.ts +64 -0
- package/src/modes/interactive/components/hook-message.ts +96 -0
- package/src/modes/interactive/components/hook-selector.ts +91 -0
- package/src/modes/interactive/components/model-selector.ts +247 -0
- package/src/modes/interactive/components/oauth-selector.ts +120 -0
- package/src/modes/interactive/components/plugin-settings.ts +479 -0
- package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
- package/src/modes/interactive/components/session-selector.ts +204 -0
- package/src/modes/interactive/components/settings-selector.ts +453 -0
- package/src/modes/interactive/components/show-images-selector.ts +45 -0
- package/src/modes/interactive/components/theme-selector.ts +62 -0
- package/src/modes/interactive/components/thinking-selector.ts +64 -0
- package/src/modes/interactive/components/tool-execution.ts +675 -0
- package/src/modes/interactive/components/tree-selector.ts +866 -0
- package/src/modes/interactive/components/user-message-selector.ts +159 -0
- package/src/modes/interactive/components/user-message.ts +18 -0
- package/src/modes/interactive/components/visual-truncate.ts +50 -0
- package/src/modes/interactive/components/welcome.ts +183 -0
- package/src/modes/interactive/interactive-mode.ts +2516 -0
- package/src/modes/interactive/theme/dark.json +101 -0
- package/src/modes/interactive/theme/light.json +98 -0
- package/src/modes/interactive/theme/theme-schema.json +308 -0
- package/src/modes/interactive/theme/theme.ts +998 -0
- package/src/modes/print-mode.ts +128 -0
- package/src/modes/rpc/rpc-client.ts +527 -0
- package/src/modes/rpc/rpc-mode.ts +483 -0
- package/src/modes/rpc/rpc-types.ts +203 -0
- package/src/utils/changelog.ts +99 -0
- package/src/utils/clipboard.ts +265 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/shell.ts +276 -0
- 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, "&")
|
|
325
|
+
.replace(/</g, "<")
|
|
326
|
+
.replace(/>/g, ">")
|
|
327
|
+
.replace(/"/g, """)
|
|
328
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|