Merge origin/main into kitty-protocol-support

This commit is contained in:
Mario Zechner 2025-12-19 01:04:34 +01:00
commit b1be086169
32 changed files with 969 additions and 187 deletions

View file

@ -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",

View file

@ -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

View file

@ -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.

View file

@ -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:

View file

@ -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
\`\`\`
```

View file

@ -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";

View file

@ -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,
};
}
}

View file

@ -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;
}

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
// 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],
};
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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;
}

View file

@ -18,7 +18,7 @@ const findSchema = Type.Object({
const DEFAULT_LIMIT = 1000;
interface FindToolDetails {
export interface FindToolDetails {
truncation?: TruncationResult;
resultLimitReached?: number;
}

View file

@ -31,7 +31,7 @@ const grepSchema = Type.Object({
const DEFAULT_LIMIT = 100;
interface GrepToolDetails {
export interface GrepToolDetails {
truncation?: TruncationResult;
matchLimitReached?: number;
linesTruncated?: boolean;

View file

@ -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";

View file

@ -12,7 +12,7 @@ const lsSchema = Type.Object({
const DEFAULT_LIMIT = 500;
interface LsToolDetails {
export interface LsToolDetails {
truncation?: TruncationResult;
entryLimitReached?: number;
}

View file

@ -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;
}

View file

@ -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";

View file

@ -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())

View file

@ -0,0 +1,8 @@
---
name: calendar
description: First calendar skill.
---
# Calendar (First)
This is the first calendar skill.

View file

@ -0,0 +1,8 @@
---
name: calendar
description: Second calendar skill.
---
# Calendar (Second)
This is the second calendar skill.

View 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.

View 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.

View 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.

View file

@ -0,0 +1,7 @@
---
name: missing-description
---
# Missing Description
This skill has no description field.

View 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.

View file

@ -0,0 +1,8 @@
---
name: child-skill
description: A nested skill in a subdirectory.
---
# Child Skill
This skill is nested in a subdirectory.

View file

@ -0,0 +1,3 @@
# No Frontmatter
This skill has no YAML frontmatter at all.

View 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.

View 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.

View 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("&lt;special&gt;");
expect(result).toContain("&amp;");
expect(result).toContain("&quot;characters&quot;");
});
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");
});
});
});

View file

@ -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);