mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
Merge origin/main into kitty-protocol-support
This commit is contained in:
commit
b1be086169
32 changed files with 969 additions and 187 deletions
|
|
@ -6003,9 +6003,9 @@ export const MODELS = {
|
|||
contextWindow: 32768,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-3.5-haiku": {
|
||||
id: "anthropic/claude-3.5-haiku",
|
||||
name: "Anthropic: Claude 3.5 Haiku",
|
||||
"anthropic/claude-3.5-haiku-20241022": {
|
||||
id: "anthropic/claude-3.5-haiku-20241022",
|
||||
name: "Anthropic: Claude 3.5 Haiku (2024-10-22)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
|
|
@ -6020,9 +6020,9 @@ export const MODELS = {
|
|||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-3.5-haiku-20241022": {
|
||||
id: "anthropic/claude-3.5-haiku-20241022",
|
||||
name: "Anthropic: Claude 3.5 Haiku (2024-10-22)",
|
||||
"anthropic/claude-3.5-haiku": {
|
||||
id: "anthropic/claude-3.5-haiku",
|
||||
name: "Anthropic: Claude 3.5 Haiku",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
|
|
@ -6258,23 +6258,6 @@ export const MODELS = {
|
|||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-405b-instruct": {
|
||||
id: "meta-llama/llama-3.1-405b-instruct",
|
||||
name: "Meta: Llama 3.1 405B Instruct",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 3.5,
|
||||
output: 3.5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 130815,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-8b-instruct": {
|
||||
id: "meta-llama/llama-3.1-8b-instruct",
|
||||
name: "Meta: Llama 3.1 8B Instruct",
|
||||
|
|
@ -6292,6 +6275,23 @@ export const MODELS = {
|
|||
contextWindow: 131072,
|
||||
maxTokens: 16384,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-405b-instruct": {
|
||||
id: "meta-llama/llama-3.1-405b-instruct",
|
||||
name: "Meta: Llama 3.1 405B Instruct",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 3.5,
|
||||
output: 3.5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 130815,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3.1-70b-instruct": {
|
||||
id: "meta-llama/llama-3.1-70b-instruct",
|
||||
name: "Meta: Llama 3.1 70B Instruct",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,24 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Hook `tool_result` event restructured**: The `ToolResultEvent` now exposes full tool result data instead of just text. ([#233](https://github.com/badlogic/pi-mono/pull/233))
|
||||
- Removed: `result: string` field
|
||||
- Added: `content: (TextContent | ImageContent)[]` - full content array
|
||||
- Added: `details: unknown` - tool-specific details (typed per tool via discriminated union on `toolName`)
|
||||
- `ToolResultEventResult.result` renamed to `ToolResultEventResult.text` (removed), use `content` instead
|
||||
- Hook handlers returning `{ result: "..." }` must change to `{ content: [{ type: "text", text: "..." }] }`
|
||||
- Built-in tool details types exported: `BashToolDetails`, `ReadToolDetails`, `GrepToolDetails`, `FindToolDetails`, `LsToolDetails`, `TruncationResult`
|
||||
- Type guards exported for narrowing: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`
|
||||
|
||||
### Added
|
||||
|
||||
- **Kitty keyboard protocol support**: Added support for the Kitty keyboard protocol, enabling Shift+Enter, Alt+Enter, Shift+Tab, Ctrl+D to exit, and all Ctrl+key combinations to work in Ghostty, Kitty, WezTerm, and other modern terminals. (by [@kim0](https://github.com/kim0))
|
||||
- **Kitty keyboard protocol support**: Added support for the Kitty keyboard protocol, enabling Shift+Enter, Alt+Enter, Shift+Tab, Ctrl+D to exit, and all Ctrl+key combinations to work in Ghostty, Kitty, WezTerm, and other modern terminals. ([#225](https://github.com/badlogic/pi-mono/pull/225) by [@kim0](https://github.com/kim0))
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skills standard compliance**: Skills now adhere to the [Agent Skills standard](https://agentskills.io/specification). Validates name (must match parent directory, lowercase, max 64 chars), description (required, max 1024 chars), and frontmatter fields. Warns on violations but remains lenient. Prompt format changed to XML structure. Removed `{baseDir}` placeholder in favor of relative paths. ([#231](https://github.com/badlogic/pi-mono/issues/231))
|
||||
|
||||
## [0.23.4] - 2025-12-18
|
||||
|
||||
|
|
|
|||
|
|
@ -489,7 +489,9 @@ Usage: `/component Button "onClick handler" "disabled support"`
|
|||
|
||||
### Skills
|
||||
|
||||
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one.
|
||||
Skills are self-contained capability packages that the agent loads on-demand. Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about violations but remaining lenient.
|
||||
|
||||
A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one.
|
||||
|
||||
**Example use cases:**
|
||||
- Web search and content extraction (Brave Search API)
|
||||
|
|
@ -509,6 +511,7 @@ Skills are self-contained capability packages that the agent loads on-demand. A
|
|||
|
||||
```markdown
|
||||
---
|
||||
name: brave-search
|
||||
description: Web search via Brave Search API. Use for documentation, facts, or web content.
|
||||
---
|
||||
|
||||
|
|
@ -516,18 +519,18 @@ description: Web search via Brave Search API. Use for documentation, facts, or w
|
|||
|
||||
## Setup
|
||||
\`\`\`bash
|
||||
cd {baseDir} && npm install
|
||||
cd /path/to/brave-search && npm install
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
\`\`\`bash
|
||||
{baseDir}/search.js "query" # Basic search
|
||||
{baseDir}/search.js "query" --content # Include page content
|
||||
./search.js "query" # Basic search
|
||||
./search.js "query" --content # Include page content
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
- `description`: Required. Determines when the skill is loaded.
|
||||
- `{baseDir}`: Placeholder for the skill's directory.
|
||||
- `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars.
|
||||
- `description`: Required. Max 1024 chars. Determines when the skill is loaded.
|
||||
|
||||
**Disable skills:** `pi --no-skills` or set `skills.enabled: false` in settings.
|
||||
|
||||
|
|
|
|||
|
|
@ -222,13 +222,104 @@ Fired after tool executes. **Can modify result.**
|
|||
|
||||
```typescript
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
// event.toolName, event.toolCallId, event.input
|
||||
// event.result: string
|
||||
// event.toolName: string
|
||||
// event.toolCallId: string
|
||||
// event.input: Record<string, unknown>
|
||||
// event.content: (TextContent | ImageContent)[]
|
||||
// event.details: tool-specific (see below)
|
||||
// event.isError: boolean
|
||||
return { result: "modified" }; // or undefined to keep original
|
||||
|
||||
// Return modified content/details, or undefined to keep original
|
||||
return { content: [...], details: {...} };
|
||||
});
|
||||
```
|
||||
|
||||
The event type is a discriminated union based on `toolName`. Use the provided type guards to narrow `details` to the correct type:
|
||||
|
||||
```typescript
|
||||
import { isBashToolResult, type HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (isBashToolResult(event)) {
|
||||
// event.details is BashToolDetails | undefined
|
||||
if (event.details?.truncation?.truncated) {
|
||||
// Access full output from temp file
|
||||
const fullPath = event.details.fullOutputPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Available type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.
|
||||
|
||||
#### Tool Details Types
|
||||
|
||||
Each built-in tool has a typed `details` field. Types are exported from `@mariozechner/pi-coding-agent`:
|
||||
|
||||
| Tool | Details Type | Source |
|
||||
|------|-------------|--------|
|
||||
| `bash` | `BashToolDetails` | `src/core/tools/bash.ts` |
|
||||
| `read` | `ReadToolDetails` | `src/core/tools/read.ts` |
|
||||
| `edit` | `undefined` | - |
|
||||
| `write` | `undefined` | - |
|
||||
| `grep` | `GrepToolDetails` | `src/core/tools/grep.ts` |
|
||||
| `find` | `FindToolDetails` | `src/core/tools/find.ts` |
|
||||
| `ls` | `LsToolDetails` | `src/core/tools/ls.ts` |
|
||||
|
||||
Common fields in details:
|
||||
- `truncation?: TruncationResult` - present when output was truncated
|
||||
- `fullOutputPath?: string` - path to temp file with full output (bash only)
|
||||
|
||||
`TruncationResult` contains:
|
||||
- `truncated: boolean` - whether truncation occurred
|
||||
- `truncatedBy: "lines" | "bytes" | null` - which limit was hit
|
||||
- `totalLines`, `totalBytes` - original size
|
||||
- `outputLines`, `outputBytes` - truncated size
|
||||
|
||||
Custom tools use `CustomToolResultEvent` with `details: unknown`. Create your own type guard to get full type safety:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isBashToolResult,
|
||||
type CustomToolResultEvent,
|
||||
type HookAPI,
|
||||
type ToolResultEvent,
|
||||
} from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
interface MyCustomToolDetails {
|
||||
someField: string;
|
||||
}
|
||||
|
||||
// Type guard that narrows both toolName and details
|
||||
function isMyCustomToolResult(e: ToolResultEvent): e is CustomToolResultEvent & {
|
||||
toolName: "my-custom-tool";
|
||||
details: MyCustomToolDetails;
|
||||
} {
|
||||
return e.toolName === "my-custom-tool";
|
||||
}
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
// Built-in tool: use provided type guard
|
||||
if (isBashToolResult(event)) {
|
||||
if (event.details?.fullOutputPath) {
|
||||
console.log(`Full output at: ${event.details.fullOutputPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom tool: use your own type guard
|
||||
if (isMyCustomToolResult(event)) {
|
||||
// event.details is now MyCustomToolDetails
|
||||
console.log(event.details.someField);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** If you modify `content`, you should also update `details` accordingly. The TUI uses `details` (e.g., truncation info) for rendering, so inconsistent values will cause display issues.
|
||||
|
||||
## Context API
|
||||
|
||||
Every event handler receives a context object with these methods:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.
|
||||
|
||||
Pi implements the [Agent Skills standard](https://agentskills.io/specification).
|
||||
|
||||
**Example use cases:**
|
||||
- Web search and content extraction (Brave Search API)
|
||||
- Browser automation via Chrome DevTools Protocol
|
||||
|
|
@ -65,14 +67,13 @@ description: What this skill does and when to use it. Be specific.
|
|||
|
||||
Run once before first use:
|
||||
\`\`\`bash
|
||||
cd {baseDir}
|
||||
npm install
|
||||
cd /path/to/skill && npm install
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`bash
|
||||
{baseDir}/scripts/process.sh <input>
|
||||
./scripts/process.sh <input>
|
||||
\`\`\`
|
||||
|
||||
## Workflow
|
||||
|
|
@ -84,20 +85,54 @@ npm install
|
|||
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
Per the [Agent Skills specification](https://agentskills.io/specification#frontmatter-required):
|
||||
|
||||
| Field | Required | Constraints |
|
||||
|-------|----------|-------------|
|
||||
| `description` | Yes | What the skill does and when to use it |
|
||||
| `name` | No | Override skill name (defaults to directory name) |
|
||||
| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens only. Must match parent directory name. |
|
||||
| `description` | Yes | Max 1024 chars. What the skill does and when to use it. |
|
||||
| `license` | No | License name or reference to bundled license file. |
|
||||
| `compatibility` | No | Max 500 chars. Environment requirements (system packages, network access, etc.). |
|
||||
| `metadata` | No | Arbitrary key-value mapping for additional metadata. |
|
||||
| `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). |
|
||||
|
||||
The `description` is critical. It's shown in the system prompt and determines when the agent loads the skill. Be specific about both what it does and when to use it.
|
||||
#### Name Validation
|
||||
|
||||
### The `{baseDir}` Placeholder
|
||||
The `name` field must:
|
||||
- Be 1-64 characters
|
||||
- Contain only lowercase letters (a-z), numbers (0-9), and hyphens
|
||||
- Not start or end with a hyphen
|
||||
- Not contain consecutive hyphens (--)
|
||||
- Match the parent directory name exactly
|
||||
|
||||
Use `{baseDir}` to reference files in the skill's directory. The agent sees each skill's base directory and substitutes it when following instructions:
|
||||
Valid: `pdf-processing`, `data-analysis`, `code-review`
|
||||
Invalid: `PDF-Processing`, `-pdf`, `pdf--processing`
|
||||
|
||||
#### Description Best Practices
|
||||
|
||||
The `description` is critical. It determines when the agent loads the skill. Be specific about both what it does and when to use it.
|
||||
|
||||
Good:
|
||||
```yaml
|
||||
description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents or when the user mentions PDFs, forms, or document extraction.
|
||||
```
|
||||
|
||||
Poor:
|
||||
```yaml
|
||||
description: Helps with PDFs.
|
||||
```
|
||||
|
||||
### File References
|
||||
|
||||
Use relative paths from the skill directory:
|
||||
|
||||
```markdown
|
||||
Helper scripts: {baseDir}/scripts/
|
||||
Config template: {baseDir}/assets/config.json
|
||||
See [the reference guide](references/REFERENCE.md) for details.
|
||||
|
||||
Run the extraction script:
|
||||
\`\`\`bash
|
||||
./scripts/extract.py input.pdf
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
## Skill Locations
|
||||
|
|
@ -110,21 +145,28 @@ Skills are discovered from these locations (later wins on name collision):
|
|||
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive)
|
||||
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project, recursive)
|
||||
|
||||
### Subdirectory Naming
|
||||
|
||||
Pi skills in subdirectories use colon-separated names:
|
||||
- `~/.pi/agent/skills/db/migrate/SKILL.md` → `db:migrate`
|
||||
- `<cwd>/.pi/skills/aws/s3/upload/SKILL.md` → `aws:s3:upload`
|
||||
|
||||
## How Skills Work
|
||||
|
||||
1. At startup, pi scans skill locations and extracts names + descriptions
|
||||
2. The system prompt includes a list of available skills with their descriptions
|
||||
2. The system prompt includes available skills in XML format
|
||||
3. When a task matches, the agent uses `read` to load the full SKILL.md
|
||||
4. The agent follows the instructions, using `{baseDir}` to reference scripts/assets
|
||||
4. The agent follows the instructions, using relative paths to reference scripts/assets
|
||||
|
||||
This is progressive disclosure: only descriptions are always in context, full instructions load on-demand.
|
||||
|
||||
## Validation Warnings
|
||||
|
||||
Pi validates skills against the Agent Skills standard and warns (but still loads) non-compliant skills:
|
||||
|
||||
- Name doesn't match parent directory
|
||||
- Name exceeds 64 characters
|
||||
- Name contains invalid characters
|
||||
- Name starts/ends with hyphen or has consecutive hyphens
|
||||
- Description missing or exceeds 1024 characters
|
||||
- Unknown frontmatter fields
|
||||
|
||||
Name collisions (same name from different locations) warn and keep the first skill found.
|
||||
|
||||
## Example: Web Search Skill
|
||||
|
||||
```
|
||||
|
|
@ -146,21 +188,20 @@ description: Web search and content extraction via Brave Search API. Use for sea
|
|||
## Setup
|
||||
|
||||
\`\`\`bash
|
||||
cd {baseDir}
|
||||
npm install
|
||||
cd /path/to/brave-search && npm install
|
||||
\`\`\`
|
||||
|
||||
## Search
|
||||
|
||||
\`\`\`bash
|
||||
{baseDir}/search.js "query" # Basic search
|
||||
{baseDir}/search.js "query" --content # Include page content
|
||||
./search.js "query" # Basic search
|
||||
./search.js "query" --content # Include page content
|
||||
\`\`\`
|
||||
|
||||
## Extract Page Content
|
||||
|
||||
\`\`\`bash
|
||||
{baseDir}/content.js https://example.com
|
||||
./content.js https://example.com
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,22 @@ export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
|||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
ExecResult,
|
||||
FindToolResultEvent,
|
||||
GrepToolResultEvent,
|
||||
HookAPI,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
|
|
@ -20,4 +27,14 @@ export type {
|
|||
ToolResultEventResult,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./types.js";
|
||||
export {
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
isGrepToolResult,
|
||||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./types.js";
|
||||
|
|
|
|||
|
|
@ -44,26 +44,21 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
|
|||
|
||||
// Emit tool_result event - hooks can modify the result
|
||||
if (hookRunner.hasHandlers("tool_result")) {
|
||||
// Extract text from result for hooks
|
||||
const resultText = result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
const resultResult = (await hookRunner.emit({
|
||||
type: "tool_result",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
result: resultText,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError: false,
|
||||
})) as ToolResultEventResult | undefined;
|
||||
|
||||
// Apply modifications if any
|
||||
if (resultResult?.result !== undefined) {
|
||||
if (resultResult) {
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: "text", text: resultResult.result }],
|
||||
content: resultResult.content ?? result.content,
|
||||
details: (resultResult.details ?? result.details) as T,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@
|
|||
*/
|
||||
|
||||
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { ImageContent, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { SessionEntry } from "../session-manager.js";
|
||||
import type {
|
||||
BashToolDetails,
|
||||
FindToolDetails,
|
||||
GrepToolDetails,
|
||||
LsToolDetails,
|
||||
ReadToolDetails,
|
||||
} from "../tools/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// Execution Context
|
||||
|
|
@ -140,23 +147,106 @@ export interface ToolCallEvent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Event data for tool_result event.
|
||||
* Fired after a tool is executed. Hooks can modify the result.
|
||||
* Base interface for tool_result events.
|
||||
*/
|
||||
export interface ToolResultEvent {
|
||||
interface ToolResultEventBase {
|
||||
type: "tool_result";
|
||||
/** Tool name (e.g., "bash", "edit", "write") */
|
||||
toolName: string;
|
||||
/** Tool call ID */
|
||||
toolCallId: string;
|
||||
/** Tool input parameters */
|
||||
input: Record<string, unknown>;
|
||||
/** Tool result content (text) */
|
||||
result: string;
|
||||
/** Full content array (text and images) */
|
||||
content: (TextContent | ImageContent)[];
|
||||
/** Whether the tool execution was an error */
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/** Tool result event for bash tool */
|
||||
export interface BashToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "bash";
|
||||
details: BashToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for read tool */
|
||||
export interface ReadToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "read";
|
||||
details: ReadToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for edit tool */
|
||||
export interface EditToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "edit";
|
||||
details: undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for write tool */
|
||||
export interface WriteToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "write";
|
||||
details: undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for grep tool */
|
||||
export interface GrepToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "grep";
|
||||
details: GrepToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for find tool */
|
||||
export interface FindToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "find";
|
||||
details: FindToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for ls tool */
|
||||
export interface LsToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "ls";
|
||||
details: LsToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for custom/unknown tools */
|
||||
export interface CustomToolResultEvent extends ToolResultEventBase {
|
||||
toolName: string;
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for tool_result event.
|
||||
* Fired after a tool is executed. Hooks can modify the result.
|
||||
* Use toolName to discriminate and get typed details.
|
||||
*/
|
||||
export type ToolResultEvent =
|
||||
| BashToolResultEvent
|
||||
| ReadToolResultEvent
|
||||
| EditToolResultEvent
|
||||
| WriteToolResultEvent
|
||||
| GrepToolResultEvent
|
||||
| FindToolResultEvent
|
||||
| LsToolResultEvent
|
||||
| CustomToolResultEvent;
|
||||
|
||||
// Type guards for narrowing ToolResultEvent to specific tool types
|
||||
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
|
||||
return e.toolName === "bash";
|
||||
}
|
||||
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
|
||||
return e.toolName === "read";
|
||||
}
|
||||
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
|
||||
return e.toolName === "edit";
|
||||
}
|
||||
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
|
||||
return e.toolName === "write";
|
||||
}
|
||||
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
|
||||
return e.toolName === "grep";
|
||||
}
|
||||
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
|
||||
return e.toolName === "find";
|
||||
}
|
||||
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||
return e.toolName === "ls";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for branch event.
|
||||
*/
|
||||
|
|
@ -201,8 +291,10 @@ export interface ToolCallEventResult {
|
|||
* Allows hooks to modify tool results.
|
||||
*/
|
||||
export interface ToolResultEventResult {
|
||||
/** Modified result text (if not set, original result is used) */
|
||||
result?: string;
|
||||
/** Replacement content array (text and images) */
|
||||
content?: (TextContent | ImageContent)[];
|
||||
/** Replacement details */
|
||||
details?: unknown;
|
||||
/** Override isError flag */
|
||||
isError?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,29 @@ import { homedir } from "os";
|
|||
import { basename, dirname, join, resolve } from "path";
|
||||
import { CONFIG_DIR_NAME } from "../config.js";
|
||||
|
||||
/**
|
||||
* Standard frontmatter fields per Agent Skills spec.
|
||||
* See: https://agentskills.io/specification#frontmatter-required
|
||||
*/
|
||||
const ALLOWED_FRONTMATTER_FIELDS = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"license",
|
||||
"compatibility",
|
||||
"metadata",
|
||||
"allowed-tools",
|
||||
]);
|
||||
|
||||
/** Max name length per spec */
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
|
||||
/** Max description length per spec */
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
export interface SkillFrontmatter {
|
||||
name?: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
|
|
@ -16,6 +36,16 @@ export interface Skill {
|
|||
source: string;
|
||||
}
|
||||
|
||||
export interface SkillWarning {
|
||||
skillPath: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LoadSkillsResult {
|
||||
skills: Skill[];
|
||||
warnings: SkillWarning[];
|
||||
}
|
||||
|
||||
type SkillFormat = "recursive" | "claude";
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
|
|
@ -25,28 +55,30 @@ function stripQuotes(value: string): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } {
|
||||
const frontmatter: SkillFrontmatter = { description: "" };
|
||||
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string; allKeys: string[] } {
|
||||
const frontmatter: SkillFrontmatter = {};
|
||||
const allKeys: string[] = [];
|
||||
|
||||
const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
if (!normalizedContent.startsWith("---")) {
|
||||
return { frontmatter, body: normalizedContent };
|
||||
return { frontmatter, body: normalizedContent, allKeys };
|
||||
}
|
||||
|
||||
const endIndex = normalizedContent.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return { frontmatter, body: normalizedContent };
|
||||
return { frontmatter, body: normalizedContent, allKeys };
|
||||
}
|
||||
|
||||
const frontmatterBlock = normalizedContent.slice(4, endIndex);
|
||||
const body = normalizedContent.slice(endIndex + 4).trim();
|
||||
|
||||
for (const line of frontmatterBlock.split("\n")) {
|
||||
const match = line.match(/^(\w+):\s*(.*)$/);
|
||||
const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
allKeys.push(key);
|
||||
if (key === "name") {
|
||||
frontmatter.name = value;
|
||||
} else if (key === "description") {
|
||||
|
|
@ -55,7 +87,65 @@ function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; bod
|
|||
}
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
return { frontmatter, body, allKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate skill name per Agent Skills spec.
|
||||
* Returns array of validation error messages (empty if valid).
|
||||
*/
|
||||
function validateName(name: string, parentDirName: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (name !== parentDirName) {
|
||||
errors.push(`name "${name}" does not match parent directory "${parentDirName}"`);
|
||||
}
|
||||
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);
|
||||
}
|
||||
|
||||
if (name.startsWith("-") || name.endsWith("-")) {
|
||||
errors.push(`name must not start or end with a hyphen`);
|
||||
}
|
||||
|
||||
if (name.includes("--")) {
|
||||
errors.push(`name must not contain consecutive hyphens`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate description per Agent Skills spec.
|
||||
*/
|
||||
function validateDescription(description: string | undefined): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!description || description.trim() === "") {
|
||||
errors.push(`description is required`);
|
||||
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for unknown frontmatter fields.
|
||||
*/
|
||||
function validateFrontmatterFields(keys: string[]): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (!ALLOWED_FRONTMATTER_FIELDS.has(key)) {
|
||||
errors.push(`unknown frontmatter field "${key}"`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export interface LoadSkillsFromDirOptions {
|
||||
|
|
@ -63,30 +153,23 @@ export interface LoadSkillsFromDirOptions {
|
|||
dir: string;
|
||||
/** Source identifier for these skills */
|
||||
source: string;
|
||||
/** Use colon-separated path names (e.g., db:migrate) instead of simple directory name */
|
||||
useColonPath?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from a directory recursively.
|
||||
* Skills are directories containing a SKILL.md file with frontmatter including a description.
|
||||
*/
|
||||
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions, subdir: string = ""): Skill[] {
|
||||
const { dir, source, useColonPath = false } = options;
|
||||
return loadSkillsFromDirInternal(dir, source, "recursive", useColonPath, subdir);
|
||||
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
|
||||
const { dir, source } = options;
|
||||
return loadSkillsFromDirInternal(dir, source, "recursive");
|
||||
}
|
||||
|
||||
function loadSkillsFromDirInternal(
|
||||
dir: string,
|
||||
source: string,
|
||||
format: SkillFormat,
|
||||
useColonPath: boolean = false,
|
||||
subdir: string = "",
|
||||
): Skill[] {
|
||||
function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFormat): LoadSkillsResult {
|
||||
const skills: Skill[] = [];
|
||||
const warnings: SkillWarning[] = [];
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
return skills;
|
||||
return { skills, warnings };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -106,30 +189,15 @@ function loadSkillsFromDirInternal(
|
|||
if (format === "recursive") {
|
||||
// Recursive format: scan directories, look for SKILL.md files
|
||||
if (entry.isDirectory()) {
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
skills.push(...loadSkillsFromDirInternal(fullPath, source, format, useColonPath, newSubdir));
|
||||
const subResult = loadSkillsFromDirInternal(fullPath, source, format);
|
||||
skills.push(...subResult.skills);
|
||||
warnings.push(...subResult.warnings);
|
||||
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillDir = dirname(fullPath);
|
||||
// useColonPath: db:migrate (pi), otherwise just: migrate (codex)
|
||||
const nameFromPath = useColonPath ? subdir || basename(skillDir) : basename(skillDir);
|
||||
const name = frontmatter.name || nameFromPath;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: fullPath,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
const result = loadSkillFromFile(fullPath, source);
|
||||
if (result.skill) {
|
||||
skills.push(result.skill);
|
||||
}
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
} else if (format === "claude") {
|
||||
// Claude format: only one level deep, each directory must contain SKILL.md
|
||||
|
|
@ -137,40 +205,77 @@ function loadSkillsFromDirInternal(
|
|||
continue;
|
||||
}
|
||||
|
||||
const skillDir = fullPath;
|
||||
const skillFile = join(skillDir, "SKILL.md");
|
||||
|
||||
const skillFile = join(fullPath, "SKILL.md");
|
||||
if (!existsSync(skillFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(skillFile, "utf-8");
|
||||
const { frontmatter } = parseFrontmatter(rawContent);
|
||||
|
||||
if (!frontmatter.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = frontmatter.name || entry.name;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath: skillFile,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
});
|
||||
} catch {}
|
||||
const result = loadSkillFromFile(skillFile, source);
|
||||
if (result.skill) {
|
||||
skills.push(result.skill);
|
||||
}
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return skills;
|
||||
return { skills, warnings };
|
||||
}
|
||||
|
||||
function loadSkillFromFile(filePath: string, source: string): { skill: Skill | null; warnings: SkillWarning[] } {
|
||||
const warnings: SkillWarning[] = [];
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(filePath, "utf-8");
|
||||
const { frontmatter, allKeys } = parseFrontmatter(rawContent);
|
||||
const skillDir = dirname(filePath);
|
||||
const parentDirName = basename(skillDir);
|
||||
|
||||
// Validate frontmatter fields
|
||||
const fieldErrors = validateFrontmatterFields(allKeys);
|
||||
for (const error of fieldErrors) {
|
||||
warnings.push({ skillPath: filePath, message: error });
|
||||
}
|
||||
|
||||
// Validate description
|
||||
const descErrors = validateDescription(frontmatter.description);
|
||||
for (const error of descErrors) {
|
||||
warnings.push({ skillPath: filePath, message: error });
|
||||
}
|
||||
|
||||
// Use name from frontmatter, or fall back to parent directory name
|
||||
const name = frontmatter.name || parentDirName;
|
||||
|
||||
// Validate name
|
||||
const nameErrors = validateName(name, parentDirName);
|
||||
for (const error of nameErrors) {
|
||||
warnings.push({ skillPath: filePath, message: error });
|
||||
}
|
||||
|
||||
// Still load the skill even with warnings (unless description is completely missing)
|
||||
if (!frontmatter.description || frontmatter.description.trim() === "") {
|
||||
return { skill: null, warnings };
|
||||
}
|
||||
|
||||
return {
|
||||
skill: {
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
filePath,
|
||||
baseDir: skillDir,
|
||||
source,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
} catch {
|
||||
return { skill: null, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format skills for inclusion in a system prompt.
|
||||
* Uses XML format per Agent Skills standard.
|
||||
* See: https://agentskills.io/integrate-skills
|
||||
*/
|
||||
export function formatSkillsForPrompt(skills: Skill[]): string {
|
||||
if (skills.length === 0) {
|
||||
|
|
@ -178,16 +283,18 @@ export function formatSkillsForPrompt(skills: Skill[]): string {
|
|||
}
|
||||
|
||||
const lines = [
|
||||
"\n\n<available_skills>",
|
||||
"The following skills provide specialized instructions for specific tasks.",
|
||||
"\n\nThe following skills provide specialized instructions for specific tasks.",
|
||||
"Use the read tool to load a skill's file when the task matches its description.",
|
||||
"Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n",
|
||||
"",
|
||||
"<available_skills>",
|
||||
];
|
||||
|
||||
for (const skill of skills) {
|
||||
lines.push(`- ${skill.name}: ${skill.description}`);
|
||||
lines.push(` File: ${skill.filePath}`);
|
||||
lines.push(` Base directory: ${skill.baseDir}`);
|
||||
lines.push(" <skill>");
|
||||
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
||||
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
||||
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
||||
lines.push(" </skill>");
|
||||
}
|
||||
|
||||
lines.push("</available_skills>");
|
||||
|
|
@ -195,36 +302,59 @@ export function formatSkillsForPrompt(skills: Skill[]): string {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function loadSkills(): Skill[] {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Codex: recursive, simple directory name
|
||||
const codexUserDir = join(homedir(), ".codex", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive", false)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
/**
|
||||
* Load skills from all configured locations.
|
||||
* Returns skills and any validation warnings.
|
||||
*/
|
||||
export function loadSkills(): LoadSkillsResult {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
const allWarnings: SkillWarning[] = [];
|
||||
const collisionWarnings: SkillWarning[] = [];
|
||||
|
||||
function addSkills(result: LoadSkillsResult) {
|
||||
allWarnings.push(...result.warnings);
|
||||
for (const skill of result.skills) {
|
||||
const existing = skillMap.get(skill.name);
|
||||
if (existing) {
|
||||
collisionWarnings.push({
|
||||
skillPath: skill.filePath,
|
||||
message: `name collision: "${skill.name}" already loaded from ${existing.filePath}, skipping this one`,
|
||||
});
|
||||
} else {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex: recursive
|
||||
const codexUserDir = join(homedir(), ".codex", "skills");
|
||||
addSkills(loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive"));
|
||||
|
||||
// Claude: single level only
|
||||
const claudeUserDir = join(homedir(), ".claude", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude", false)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude"));
|
||||
|
||||
const claudeProjectDir = resolve(process.cwd(), ".claude", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude", false)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude"));
|
||||
|
||||
// Pi: recursive, colon-separated path names
|
||||
// Pi: recursive
|
||||
const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive", true)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive"));
|
||||
|
||||
const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills");
|
||||
for (const skill of loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive", true)) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
addSkills(loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive"));
|
||||
|
||||
return Array.from(skillMap.values());
|
||||
return {
|
||||
skills: Array.from(skillMap.values()),
|
||||
warnings: [...allWarnings, ...collisionWarnings],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function substituteArgs(content: string, args: string[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for .md files and load them as slash commands
|
||||
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands
|
||||
*/
|
||||
function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] {
|
||||
const commands: FileSlashCommand[] = [];
|
||||
|
|
@ -118,7 +118,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
|
|||
// Recurse into subdirectory
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
commands.push(...loadCommandsFromDir(fullPath, source, newSubdir));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
} else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) {
|
||||
try {
|
||||
const rawContent = readFileSync(fullPath, "utf-8");
|
||||
const { frontmatter, content } = parseFrontmatter(rawContent);
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
// Append skills section (only if read tool is available)
|
||||
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
|
||||
if (skillsEnabled && customPromptHasRead) {
|
||||
const skills = loadSkills();
|
||||
const { skills } = loadSkills();
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +255,7 @@ Documentation:
|
|||
|
||||
// Append skills section (only if read tool is available)
|
||||
if (skillsEnabled && hasRead) {
|
||||
const skills = loadSkills();
|
||||
const { skills } = loadSkills();
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const bashSchema = Type.Object({
|
|||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||
});
|
||||
|
||||
interface BashToolDetails {
|
||||
export interface BashToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const findSchema = Type.Object({
|
|||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
interface FindToolDetails {
|
||||
export interface FindToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
resultLimitReached?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const grepSchema = Type.Object({
|
|||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
interface GrepToolDetails {
|
||||
export interface GrepToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
matchLimitReached?: number;
|
||||
linesTruncated?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
export { bashTool } from "./bash.js";
|
||||
export { type BashToolDetails, bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { findTool } from "./find.js";
|
||||
export { grepTool } from "./grep.js";
|
||||
export { lsTool } from "./ls.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { type FindToolDetails, findTool } from "./find.js";
|
||||
export { type GrepToolDetails, grepTool } from "./grep.js";
|
||||
export { type LsToolDetails, lsTool } from "./ls.js";
|
||||
export { type ReadToolDetails, readTool } from "./read.js";
|
||||
export type { TruncationResult } from "./truncate.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { bashTool } from "./bash.js";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const lsSchema = Type.Object({
|
|||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
interface LsToolDetails {
|
||||
export interface LsToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
entryLimitReached?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const readSchema = Type.Object({
|
|||
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
interface ReadToolDetails {
|
||||
export interface ReadToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,17 +35,23 @@ export type {
|
|||
ToolUIContext,
|
||||
} from "./core/custom-tools/index.js";
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
|
||||
// Hook system types
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
FindToolResultEvent,
|
||||
GrepToolResultEvent,
|
||||
HookAPI,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
|
|
@ -53,6 +59,17 @@ export type {
|
|||
ToolResultEventResult,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./core/hooks/index.js";
|
||||
// Hook system types and type guards
|
||||
export {
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
isGrepToolResult,
|
||||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./core/hooks/index.js";
|
||||
export { messageTransformer } from "./core/messages.js";
|
||||
export {
|
||||
|
|
@ -81,13 +98,30 @@ export {
|
|||
export {
|
||||
formatSkillsForPrompt,
|
||||
type LoadSkillsFromDirOptions,
|
||||
type LoadSkillsResult,
|
||||
loadSkills,
|
||||
loadSkillsFromDir,
|
||||
type Skill,
|
||||
type SkillFrontmatter,
|
||||
type SkillWarning,
|
||||
} from "./core/skills.js";
|
||||
// Tools
|
||||
export { bashTool, codingTools, editTool, readTool, writeTool } from "./core/tools/index.js";
|
||||
export {
|
||||
type BashToolDetails,
|
||||
bashTool,
|
||||
codingTools,
|
||||
editTool,
|
||||
type FindToolDetails,
|
||||
findTool,
|
||||
type GrepToolDetails,
|
||||
grepTool,
|
||||
type LsToolDetails,
|
||||
lsTool,
|
||||
type ReadToolDetails,
|
||||
readTool,
|
||||
type TruncationResult,
|
||||
writeTool,
|
||||
} from "./core/tools/index.js";
|
||||
|
||||
// Main entry point
|
||||
export { main } from "./main.js";
|
||||
|
|
|
|||
|
|
@ -305,13 +305,20 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
// Show loaded skills
|
||||
const skills = loadSkills();
|
||||
const { skills, warnings: skillWarnings } = loadSkills();
|
||||
if (skills.length > 0) {
|
||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show skill warnings if any
|
||||
if (skillWarnings.length > 0) {
|
||||
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show loaded custom tools
|
||||
if (this.customTools.size > 0) {
|
||||
const toolList = Array.from(this.customTools.values())
|
||||
|
|
|
|||
8
packages/coding-agent/test/fixtures/skills-collision/first/calendar/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills-collision/first/calendar/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: calendar
|
||||
description: First calendar skill.
|
||||
---
|
||||
|
||||
# Calendar (First)
|
||||
|
||||
This is the first calendar skill.
|
||||
8
packages/coding-agent/test/fixtures/skills-collision/second/calendar/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills-collision/second/calendar/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: calendar
|
||||
description: Second calendar skill.
|
||||
---
|
||||
|
||||
# Calendar (Second)
|
||||
|
||||
This is the second calendar skill.
|
||||
8
packages/coding-agent/test/fixtures/skills/consecutive-hyphens/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/consecutive-hyphens/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: bad--name
|
||||
description: A skill with consecutive hyphens in the name.
|
||||
---
|
||||
|
||||
# Consecutive Hyphens
|
||||
|
||||
This skill has consecutive hyphens in its name.
|
||||
8
packages/coding-agent/test/fixtures/skills/invalid-name-chars/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/invalid-name-chars/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: Invalid_Name
|
||||
description: A skill with invalid characters in the name.
|
||||
---
|
||||
|
||||
# Invalid Name
|
||||
|
||||
This skill has uppercase and underscore in the name.
|
||||
8
packages/coding-agent/test/fixtures/skills/long-name/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/long-name/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: this-is-a-very-long-skill-name-that-exceeds-the-sixty-four-character-limit-set-by-the-standard
|
||||
description: A skill with a name that exceeds 64 characters.
|
||||
---
|
||||
|
||||
# Long Name
|
||||
|
||||
This skill's name is too long.
|
||||
7
packages/coding-agent/test/fixtures/skills/missing-description/SKILL.md
vendored
Normal file
7
packages/coding-agent/test/fixtures/skills/missing-description/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: missing-description
|
||||
---
|
||||
|
||||
# Missing Description
|
||||
|
||||
This skill has no description field.
|
||||
8
packages/coding-agent/test/fixtures/skills/name-mismatch/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/name-mismatch/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: different-name
|
||||
description: A skill with a name that doesn't match the directory.
|
||||
---
|
||||
|
||||
# Name Mismatch
|
||||
|
||||
This skill's name doesn't match its parent directory.
|
||||
8
packages/coding-agent/test/fixtures/skills/nested/child-skill/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/nested/child-skill/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: child-skill
|
||||
description: A nested skill in a subdirectory.
|
||||
---
|
||||
|
||||
# Child Skill
|
||||
|
||||
This skill is nested in a subdirectory.
|
||||
3
packages/coding-agent/test/fixtures/skills/no-frontmatter/SKILL.md
vendored
Normal file
3
packages/coding-agent/test/fixtures/skills/no-frontmatter/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# No Frontmatter
|
||||
|
||||
This skill has no YAML frontmatter at all.
|
||||
10
packages/coding-agent/test/fixtures/skills/unknown-field/SKILL.md
vendored
Normal file
10
packages/coding-agent/test/fixtures/skills/unknown-field/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: unknown-field
|
||||
description: A skill with an unknown frontmatter field.
|
||||
author: someone
|
||||
version: 1.0
|
||||
---
|
||||
|
||||
# Unknown Field
|
||||
|
||||
This skill has non-standard frontmatter fields.
|
||||
8
packages/coding-agent/test/fixtures/skills/valid-skill/SKILL.md
vendored
Normal file
8
packages/coding-agent/test/fixtures/skills/valid-skill/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: valid-skill
|
||||
description: A valid skill for testing purposes.
|
||||
---
|
||||
|
||||
# Valid Skill
|
||||
|
||||
This is a valid skill that follows the Agent Skills standard.
|
||||
272
packages/coding-agent/test/skills.test.ts
Normal file
272
packages/coding-agent/test/skills.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { join, resolve } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSkillsForPrompt, loadSkillsFromDir, type Skill } from "../src/core/skills.js";
|
||||
|
||||
const fixturesDir = resolve(__dirname, "fixtures/skills");
|
||||
const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision");
|
||||
|
||||
describe("skills", () => {
|
||||
describe("loadSkillsFromDir", () => {
|
||||
it("should load a valid skill", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "valid-skill"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("valid-skill");
|
||||
expect(skills[0].description).toBe("A valid skill for testing purposes.");
|
||||
expect(skills[0].source).toBe("test");
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should warn when name doesn't match parent directory", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "name-mismatch"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("different-name");
|
||||
expect(warnings.some((w) => w.message.includes("does not match parent directory"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn when name contains invalid characters", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "invalid-name-chars"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(warnings.some((w) => w.message.includes("invalid characters"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn when name exceeds 64 characters", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "long-name"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(warnings.some((w) => w.message.includes("exceeds 64 characters"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn and skip skill when description is missing", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "missing-description"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(warnings.some((w) => w.message.includes("description is required"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn when unknown frontmatter fields are present", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "unknown-field"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(warnings.some((w) => w.message.includes('unknown frontmatter field "author"'))).toBe(true);
|
||||
expect(warnings.some((w) => w.message.includes('unknown frontmatter field "version"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("should load nested skills recursively", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "nested"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("child-skill");
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should skip files without frontmatter", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "no-frontmatter"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
// no-frontmatter has no description, so it should be skipped
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(warnings.some((w) => w.message.includes("description is required"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should warn when name contains consecutive hyphens", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "consecutive-hyphens"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(warnings.some((w) => w.message.includes("consecutive hyphens"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should load all skills from fixture directory", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: fixturesDir,
|
||||
source: "test",
|
||||
});
|
||||
|
||||
// Should load all skills that have descriptions (even with warnings)
|
||||
// valid-skill, name-mismatch, invalid-name-chars, long-name, unknown-field, nested/child-skill, consecutive-hyphens
|
||||
// NOT: missing-description, no-frontmatter (both missing descriptions)
|
||||
expect(skills.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("should return empty for non-existent directory", () => {
|
||||
const { skills, warnings } = loadSkillsFromDir({
|
||||
dir: "/non/existent/path",
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should use parent directory name when name not in frontmatter", () => {
|
||||
// The no-frontmatter fixture has no name in frontmatter, so it should use "no-frontmatter"
|
||||
// But it also has no description, so it won't load
|
||||
// Let's test with a valid skill that relies on directory name
|
||||
const { skills } = loadSkillsFromDir({
|
||||
dir: join(fixturesDir, "valid-skill"),
|
||||
source: "test",
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe("valid-skill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillsForPrompt", () => {
|
||||
it("should return empty string for no skills", () => {
|
||||
const result = formatSkillsForPrompt([]);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should format skills as XML", () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: "test-skill",
|
||||
description: "A test skill.",
|
||||
filePath: "/path/to/skill/SKILL.md",
|
||||
baseDir: "/path/to/skill",
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<available_skills>");
|
||||
expect(result).toContain("</available_skills>");
|
||||
expect(result).toContain("<skill>");
|
||||
expect(result).toContain("<name>test-skill</name>");
|
||||
expect(result).toContain("<description>A test skill.</description>");
|
||||
expect(result).toContain("<location>/path/to/skill/SKILL.md</location>");
|
||||
});
|
||||
|
||||
it("should include intro text before XML", () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: "test-skill",
|
||||
description: "A test skill.",
|
||||
filePath: "/path/to/skill/SKILL.md",
|
||||
baseDir: "/path/to/skill",
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
const xmlStart = result.indexOf("<available_skills>");
|
||||
const introText = result.substring(0, xmlStart);
|
||||
|
||||
expect(introText).toContain("The following skills provide specialized instructions");
|
||||
expect(introText).toContain("Use the read tool to load a skill's file");
|
||||
});
|
||||
|
||||
it("should escape XML special characters", () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: "test-skill",
|
||||
description: 'A skill with <special> & "characters".',
|
||||
filePath: "/path/to/skill/SKILL.md",
|
||||
baseDir: "/path/to/skill",
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<special>");
|
||||
expect(result).toContain("&");
|
||||
expect(result).toContain(""characters"");
|
||||
});
|
||||
|
||||
it("should format multiple skills", () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: "skill-one",
|
||||
description: "First skill.",
|
||||
filePath: "/path/one/SKILL.md",
|
||||
baseDir: "/path/one",
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
name: "skill-two",
|
||||
description: "Second skill.",
|
||||
filePath: "/path/two/SKILL.md",
|
||||
baseDir: "/path/two",
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatSkillsForPrompt(skills);
|
||||
|
||||
expect(result).toContain("<name>skill-one</name>");
|
||||
expect(result).toContain("<name>skill-two</name>");
|
||||
expect((result.match(/<skill>/g) || []).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collision handling", () => {
|
||||
it("should detect name collisions and keep first skill", () => {
|
||||
// Load from first directory
|
||||
const first = loadSkillsFromDir({
|
||||
dir: join(collisionFixturesDir, "first"),
|
||||
source: "first",
|
||||
});
|
||||
|
||||
const second = loadSkillsFromDir({
|
||||
dir: join(collisionFixturesDir, "second"),
|
||||
source: "second",
|
||||
});
|
||||
|
||||
// Simulate the collision behavior from loadSkills()
|
||||
const skillMap = new Map<string, Skill>();
|
||||
const collisionWarnings: Array<{ skillPath: string; message: string }> = [];
|
||||
|
||||
for (const skill of first.skills) {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
|
||||
for (const skill of second.skills) {
|
||||
const existing = skillMap.get(skill.name);
|
||||
if (existing) {
|
||||
collisionWarnings.push({
|
||||
skillPath: skill.filePath,
|
||||
message: `name collision: "${skill.name}" already loaded from ${existing.filePath}`,
|
||||
});
|
||||
} else {
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
}
|
||||
|
||||
expect(skillMap.size).toBe(1);
|
||||
expect(skillMap.get("calendar")?.source).toBe("first");
|
||||
expect(collisionWarnings).toHaveLength(1);
|
||||
expect(collisionWarnings[0].message).toContain("name collision");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -118,7 +118,7 @@ function loadMomSkills(channelDir: string, workspacePath: string): Skill[] {
|
|||
|
||||
// Load workspace-level skills (global)
|
||||
const workspaceSkillsDir = join(hostWorkspacePath, "skills");
|
||||
for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" })) {
|
||||
for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" }).skills) {
|
||||
// Translate paths to container paths for system prompt
|
||||
skill.filePath = translatePath(skill.filePath);
|
||||
skill.baseDir = translatePath(skill.baseDir);
|
||||
|
|
@ -127,7 +127,7 @@ function loadMomSkills(channelDir: string, workspacePath: string): Skill[] {
|
|||
|
||||
// Load channel-specific skills (override workspace skills on collision)
|
||||
const channelSkillsDir = join(channelDir, "skills");
|
||||
for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" })) {
|
||||
for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" }).skills) {
|
||||
skill.filePath = translatePath(skill.filePath);
|
||||
skill.baseDir = translatePath(skill.baseDir);
|
||||
skillMap.set(skill.name, skill);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue