From 7342607a9397b45dc9d853d067173d395ad14e65 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 15 Jan 2026 20:12:01 -0800 Subject: [PATCH 1/8] Prototype: Skills support for AI SDK --- biome.json | 2 +- examples/skills-tool/README.md | 104 +++++++ examples/skills-tool/index.ts | 103 +++++++ examples/skills-tool/skills/csv/SKILL.md | 50 ++++ examples/skills-tool/skills/csv/analyze.sh | 42 +++ examples/skills-tool/skills/csv/filter.sh | 23 ++ examples/skills-tool/skills/csv/select.sh | 18 ++ examples/skills-tool/skills/csv/sort.sh | 36 +++ examples/skills-tool/skills/text/SKILL.md | 51 ++++ examples/skills-tool/skills/text/extract.sh | 35 +++ examples/skills-tool/skills/text/search.sh | 41 +++ examples/skills-tool/skills/text/stats.sh | 33 +++ examples/skills-tool/skills/text/wordfreq.sh | 40 +++ package.json | 3 +- pnpm-lock.yaml | 67 +++++ src/index.ts | 8 + src/skill-tool.test.ts | 164 +++++++++++ src/skill-tool.ts | 118 ++++++++ src/skills/parser.test.ts | 286 +++++++++++++++++++ src/skills/parser.ts | 152 ++++++++++ src/skills/types.ts | 61 ++++ src/tools/load-skill.ts | 92 ++++++ tsconfig.examples.json | 10 + 23 files changed, 1537 insertions(+), 2 deletions(-) create mode 100644 examples/skills-tool/README.md create mode 100644 examples/skills-tool/index.ts create mode 100644 examples/skills-tool/skills/csv/SKILL.md create mode 100644 examples/skills-tool/skills/csv/analyze.sh create mode 100644 examples/skills-tool/skills/csv/filter.sh create mode 100644 examples/skills-tool/skills/csv/select.sh create mode 100644 examples/skills-tool/skills/csv/sort.sh create mode 100644 examples/skills-tool/skills/text/SKILL.md create mode 100644 examples/skills-tool/skills/text/extract.sh create mode 100644 examples/skills-tool/skills/text/search.sh create mode 100644 examples/skills-tool/skills/text/stats.sh create mode 100644 examples/skills-tool/skills/text/wordfreq.sh create mode 100644 src/skill-tool.test.ts create mode 100644 src/skill-tool.ts create mode 100644 src/skills/parser.test.ts create mode 100644 src/skills/parser.ts create mode 100644 src/skills/types.ts create mode 100644 src/tools/load-skill.ts create mode 100644 tsconfig.examples.json diff --git a/biome.json b/biome.json index d7b601d..1236130 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,6 @@ "indentWidth": 2 }, "files": { - "includes": ["src/**", "*.json", "*.ts"] + "includes": ["src/**", "examples/**", "*.json", "*.ts"] } } diff --git a/examples/skills-tool/README.md b/examples/skills-tool/README.md new file mode 100644 index 0000000..c9e14fe --- /dev/null +++ b/examples/skills-tool/README.md @@ -0,0 +1,104 @@ +# Skills Tool Example + +This example demonstrates how to use `createSkillTool` with AI SDK's `ToolLoopAgent` to give an AI agent modular capabilities (skills) that it can discover and use on demand. + +## Overview + +The example includes two bash-based skills: + +- **csv** - Analyze and transform CSV files using awk, cut, sort +- **text** - Analyze and search text files using grep, sed, wc + +## How It Works + +1. `createSkillTool` discovers skills and returns their files +2. Files are passed to `createBashTool` for sandbox upload +3. The `ToolLoopAgent`: + - Sees available skills in the `loadSkill` tool description + - Calls `loadSkill("csv")` to get detailed instructions + - Uses `bash` to run the skill's scripts + - Loops until the task is complete + +## Running the Example + +```bash +# From the repository root +npx tsx examples/skills-tool/index.ts +``` + +Requires `ANTHROPIC_API_KEY` environment variable. + +## Code Overview + +```typescript +import { ToolLoopAgent } from "ai"; +import { createBashTool, createSkillTool } from "bash-tool"; + +// Discover skills and get files +const { loadSkill, skills, files, instructions } = await createSkillTool({ + skillsDirectory: "./skills", +}); + +// Create bash tool with skill files +const { tools } = await createBashTool({ + files, + extraInstructions: instructions, +}); + +// Create agent with both tools +const agent = new ToolLoopAgent({ + model: "anthropic/claude-haiku-4.5", + tools: { + loadSkill, + bash: tools.bash, + }, +}); + +// Run the agent +const result = await agent.generate({ + prompt: "Analyze this CSV data...", +}); +``` + +## Skill Structure + +Each skill is a directory containing: + +``` +skills/ +├── csv/ +│ ├── SKILL.md # Instructions (YAML frontmatter + markdown) +│ ├── analyze.sh # Bash scripts +│ ├── filter.sh +│ ├── select.sh +│ └── sort.sh +└── text/ + ├── SKILL.md + ├── stats.sh + ├── search.sh + ├── extract.sh + └── wordfreq.sh +``` + +## Creating Your Own Skills + +1. Create a directory under `skills/` +2. Add a `SKILL.md` with frontmatter: + ```yaml + --- + name: my-skill + description: What this skill does + --- + + # Instructions for the AI + + Explain how to use the scripts... + ``` +3. Add bash scripts that the AI can execute + +## Key Concepts + +- **Composable**: `createSkillTool` returns files, you control the sandbox via `createBashTool` +- **ToolLoopAgent**: AI SDK's agent that automatically loops through tool calls until done +- **Progressive disclosure**: The AI only sees skill names initially, loading full instructions on demand +- **Bash-only**: Scripts use standard Unix tools (awk, sed, grep, sort, etc.) diff --git a/examples/skills-tool/index.ts b/examples/skills-tool/index.ts new file mode 100644 index 0000000..234d203 --- /dev/null +++ b/examples/skills-tool/index.ts @@ -0,0 +1,103 @@ +/** + * Example: Using createSkillTool with AI SDK ToolLoopAgent + * + * This example demonstrates how to create an AI agent with skills + * that can process CSV and text files using bash tools. + * + * Run with: npx tsx examples/skills-tool/index.ts + */ + +import path from "node:path"; +import { ToolLoopAgent } from "ai"; +import { createBashTool, createSkillTool } from "../../src/index.js"; + +async function main() { + // Discover skills and get files to upload + const { loadSkill, skills, files, instructions } = await createSkillTool({ + skillsDirectory: path.join(import.meta.dirname, "skills"), + }); + + console.log("Available skills:"); + for (const skill of skills) { + console.log(` - ${skill.name}: ${skill.description}`); + } + console.log(""); + + // Create bash tool with skill files + const { tools } = await createBashTool({ + files, + extraInstructions: instructions, + }); + + // Create the agent with skills + const agent = new ToolLoopAgent({ + model: "anthropic/claude-haiku-4.5", + tools: { + loadSkill, + bash: tools.bash, + }, + instructions: `You are a data processing assistant with access to skills. +Use loadSkill to discover how to use a skill, then use bash to run its scripts. +Skills are located at /workspace/skills//.`, + onStepFinish: ({ toolCalls, toolResults }) => { + if (toolCalls && toolCalls.length > 0) { + for (const call of toolCalls) { + console.log(`Tool: ${call.toolName}`); + if (call.toolName === "loadSkill" && "input" in call) { + const input = call.input as { skillName: string }; + console.log(` Loading skill: ${input.skillName}`); + } else if (call.toolName === "bash" && "input" in call) { + const input = call.input as { command: string }; + console.log(` Command: ${input.command}`); + } + } + } + if (toolResults && toolResults.length > 0) { + for (const result of toolResults) { + if (result.toolName === "bash" && "output" in result) { + const output = result.output as { + stdout: string; + exitCode: number; + }; + if (output.stdout) { + console.log(` Output:\n${output.stdout.slice(0, 500)}`); + } + } + } + console.log(""); + } + }, + }); + + // Example prompt - the AI will discover and use skills as needed + const prompt = ` + I have a CSV file with sales data. Here's the content: + + date,product,quantity,price,region + 2024-01-15,Widget A,100,29.99,North + 2024-01-15,Widget B,50,49.99,South + 2024-01-16,Widget A,75,29.99,East + 2024-01-16,Widget C,200,19.99,North + 2024-01-17,Widget B,30,49.99,West + 2024-01-17,Widget A,150,29.99,North + + Please: + 1. First, write this data to a file called sales.csv + 2. Use the csv skill to analyze the file + 3. Filter to show only North region sales + 4. Sort by quantity (highest first) + `; + + console.log("Sending prompt to agent...\n"); + + const result = await agent.generate({ prompt }); + + console.log("\n=== Final Response ===\n"); + console.log(result.text); + + console.log("\n=== Agent Stats ==="); + console.log(`Steps: ${result.steps.length}`); + console.log(`Total tokens: ${result.usage.totalTokens}`); +} + +main().catch(console.error); diff --git a/examples/skills-tool/skills/csv/SKILL.md b/examples/skills-tool/skills/csv/SKILL.md new file mode 100644 index 0000000..b4d27bc --- /dev/null +++ b/examples/skills-tool/skills/csv/SKILL.md @@ -0,0 +1,50 @@ +--- +name: csv +description: Analyze and transform CSV data using bash tools +--- + +# CSV Processing Skill + +Process CSV files using standard bash tools (awk, cut, sort, grep). + +## Available Scripts + +### analyze.sh +Get statistics and summary of a CSV file. +```bash +bash /skills/csv/analyze.sh data.csv +``` + +### filter.sh +Filter rows where a column matches a value. +```bash +bash /skills/csv/filter.sh data.csv +``` + +### select.sh +Select specific columns from CSV. +```bash +bash /skills/csv/select.sh data.csv +``` + +### sort.sh +Sort CSV by a column. +```bash +bash /skills/csv/sort.sh data.csv [--numeric] [--reverse] +``` + +## Examples + +```bash +# Show CSV summary +bash /skills/csv/analyze.sh sales.csv + +# Filter where column 3 equals "active" +bash /skills/csv/filter.sh users.csv 3 active + +# Select columns 1, 2, and 4 +bash /skills/csv/select.sh data.csv 1,2,4 + +# Sort by column 2 numerically in reverse +bash /skills/csv/sort.sh data.csv 2 --numeric --reverse +``` diff --git a/examples/skills-tool/skills/csv/analyze.sh b/examples/skills-tool/skills/csv/analyze.sh new file mode 100644 index 0000000..067db49 --- /dev/null +++ b/examples/skills-tool/skills/csv/analyze.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Analyze a CSV file - show structure and statistics + +if [ -z "$1" ]; then + echo "Usage: analyze.sh " >&2 + exit 1 +fi + +FILE="$1" + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +echo "=== CSV Analysis: $FILE ===" +echo "" + +# Row count +TOTAL_ROWS=$(wc -l < "$FILE" | tr -d ' ') +echo "Total rows: $TOTAL_ROWS" + +# Column count (from header) +HEADER=$(head -1 "$FILE") +COL_COUNT=$(echo "$HEADER" | awk -F',' '{print NF}') +echo "Columns: $COL_COUNT" + +echo "" +echo "=== Header ===" +echo "$HEADER" + +echo "" +echo "=== Column Names ===" +echo "$HEADER" | tr ',' '\n' | nl + +echo "" +echo "=== First 5 Data Rows ===" +head -6 "$FILE" | tail -5 + +echo "" +echo "=== Last 3 Data Rows ===" +tail -3 "$FILE" diff --git a/examples/skills-tool/skills/csv/filter.sh b/examples/skills-tool/skills/csv/filter.sh new file mode 100644 index 0000000..e97acaa --- /dev/null +++ b/examples/skills-tool/skills/csv/filter.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Filter CSV rows where a column matches a value + +if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then + echo "Usage: filter.sh " >&2 + echo "Example: filter.sh data.csv 3 active" >&2 + exit 1 +fi + +FILE="$1" +COL="$2" +VALUE="$3" + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +# Print header +head -1 "$FILE" + +# Filter rows where column matches value +tail -n +2 "$FILE" | awk -F',' -v col="$COL" -v val="$VALUE" '$col == val' diff --git a/examples/skills-tool/skills/csv/select.sh b/examples/skills-tool/skills/csv/select.sh new file mode 100644 index 0000000..6248eb5 --- /dev/null +++ b/examples/skills-tool/skills/csv/select.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Select specific columns from a CSV + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: select.sh " >&2 + echo "Example: select.sh data.csv 1,3,5" >&2 + exit 1 +fi + +FILE="$1" +COLS="$2" + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +cut -d',' -f"$COLS" "$FILE" diff --git a/examples/skills-tool/skills/csv/sort.sh b/examples/skills-tool/skills/csv/sort.sh new file mode 100644 index 0000000..9d79100 --- /dev/null +++ b/examples/skills-tool/skills/csv/sort.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Sort CSV by a column + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: sort.sh [--numeric] [--reverse]" >&2 + echo "Example: sort.sh data.csv 2 --numeric --reverse" >&2 + exit 1 +fi + +FILE="$1" +COL="$2" +shift 2 + +SORT_OPTS="-t, -k${COL},${COL}" + +# Parse options +while [ $# -gt 0 ]; do + case "$1" in + --numeric|-n) + SORT_OPTS="$SORT_OPTS -n" + ;; + --reverse|-r) + SORT_OPTS="$SORT_OPTS -r" + ;; + esac + shift +done + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +# Print header first, then sort the rest +head -1 "$FILE" +tail -n +2 "$FILE" | sort $SORT_OPTS diff --git a/examples/skills-tool/skills/text/SKILL.md b/examples/skills-tool/skills/text/SKILL.md new file mode 100644 index 0000000..5c9d42a --- /dev/null +++ b/examples/skills-tool/skills/text/SKILL.md @@ -0,0 +1,51 @@ +--- +name: text +description: Analyze and transform text files using bash tools +--- + +# Text Processing Skill + +Process text files using standard bash tools (grep, sed, awk, wc). + +## Available Scripts + +### stats.sh +Get statistics about a text file (lines, words, characters). +```bash +bash /skills/text/stats.sh document.txt +``` + +### search.sh +Search for patterns in text files. +```bash +bash /skills/text/search.sh [--count] [--context ] +``` + +### extract.sh +Extract specific lines or sections from a file. +```bash +bash /skills/text/extract.sh --lines - +bash /skills/text/extract.sh --between +``` + +### wordfreq.sh +Count word frequencies in a text file. +```bash +bash /skills/text/wordfreq.sh document.txt [--top ] +``` + +## Examples + +```bash +# Get file statistics +bash /skills/text/stats.sh readme.txt + +# Search with context +bash /skills/text/search.sh log.txt "ERROR" --context 2 + +# Extract lines 10-20 +bash /skills/text/extract.sh file.txt --lines 10-20 + +# Top 10 most frequent words +bash /skills/text/wordfreq.sh article.txt --top 10 +``` diff --git a/examples/skills-tool/skills/text/extract.sh b/examples/skills-tool/skills/text/extract.sh new file mode 100644 index 0000000..af3afa6 --- /dev/null +++ b/examples/skills-tool/skills/text/extract.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Extract specific lines or sections from a file + +if [ -z "$1" ]; then + echo "Usage: extract.sh --lines -" >&2 + echo " extract.sh --between " >&2 + exit 1 +fi + +FILE="$1" +shift + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +case "$1" in + --lines|-l) + RANGE="$2" + START=$(echo "$RANGE" | cut -d'-' -f1) + END=$(echo "$RANGE" | cut -d'-' -f2) + sed -n "${START},${END}p" "$FILE" + ;; + --between|-b) + START_PAT="$2" + END_PAT="$3" + sed -n "/${START_PAT}/,/${END_PAT}/p" "$FILE" + ;; + *) + echo "Unknown option: $1" >&2 + echo "Use --lines or --between" >&2 + exit 1 + ;; +esac diff --git a/examples/skills-tool/skills/text/search.sh b/examples/skills-tool/skills/text/search.sh new file mode 100644 index 0000000..bee4336 --- /dev/null +++ b/examples/skills-tool/skills/text/search.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Search for patterns in text files + +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: search.sh [--count] [--context ]" >&2 + exit 1 +fi + +FILE="$1" +PATTERN="$2" +shift 2 + +COUNT_ONLY=false +CONTEXT=0 + +while [ $# -gt 0 ]; do + case "$1" in + --count|-c) + COUNT_ONLY=true + ;; + --context|-C) + CONTEXT="$2" + shift + ;; + esac + shift +done + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +if [ "$COUNT_ONLY" = true ]; then + MATCHES=$(grep -c "$PATTERN" "$FILE") + echo "Matches found: $MATCHES" +elif [ "$CONTEXT" -gt 0 ]; then + grep -n -C "$CONTEXT" "$PATTERN" "$FILE" +else + grep -n "$PATTERN" "$FILE" +fi diff --git a/examples/skills-tool/skills/text/stats.sh b/examples/skills-tool/skills/text/stats.sh new file mode 100644 index 0000000..18a4fdd --- /dev/null +++ b/examples/skills-tool/skills/text/stats.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Get statistics about a text file + +if [ -z "$1" ]; then + echo "Usage: stats.sh " >&2 + exit 1 +fi + +FILE="$1" + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +echo "=== Text Statistics: $FILE ===" +echo "" + +LINES=$(wc -l < "$FILE" | tr -d ' ') +WORDS=$(wc -w < "$FILE" | tr -d ' ') +CHARS=$(wc -c < "$FILE" | tr -d ' ') + +echo "Lines: $LINES" +echo "Words: $WORDS" +echo "Characters: $CHARS" + +echo "" +echo "=== First 5 lines ===" +head -5 "$FILE" + +echo "" +echo "=== Last 5 lines ===" +tail -5 "$FILE" diff --git a/examples/skills-tool/skills/text/wordfreq.sh b/examples/skills-tool/skills/text/wordfreq.sh new file mode 100644 index 0000000..48fc0a5 --- /dev/null +++ b/examples/skills-tool/skills/text/wordfreq.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Count word frequencies in a text file + +if [ -z "$1" ]; then + echo "Usage: wordfreq.sh [--top ]" >&2 + exit 1 +fi + +FILE="$1" +TOP=0 +shift + +while [ $# -gt 0 ]; do + case "$1" in + --top|-t) + TOP="$2" + shift + ;; + esac + shift +done + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" >&2 + exit 1 +fi + +# Convert to lowercase, split into words, count and sort +RESULT=$(tr '[:upper:]' '[:lower:]' < "$FILE" | \ + tr -cs '[:alpha:]' '\n' | \ + grep -v '^$' | \ + sort | \ + uniq -c | \ + sort -rn) + +if [ "$TOP" -gt 0 ]; then + echo "$RESULT" | head -"$TOP" +else + echo "$RESULT" +fi diff --git a/package.json b/package.json index d9082c0..ab5b59e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "sideEffects": false, "scripts": { "build": "tsc -p tsconfig.build.json && sed '1,/^-->/d' AGENTS.npm.md > dist/AGENTS.md", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.examples.json", "test": "vitest --exclude 'src/tool.vercel.integration.test.ts'", "test:run": "vitest run --exclude 'src/tool.vercel.integration.test.ts'", "test:vercel": "vitest run src/tool.vercel.integration.test.ts --sequence.concurrent", @@ -57,6 +57,7 @@ }, "dependencies": { "fast-glob": "^3.3.2", + "gray-matter": "^4.0.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ca3456..86e26d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: fast-glob: specifier: ^3.3.2 version: 3.3.3 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 zod: specifier: ^3.23.8 version: 3.25.76 @@ -955,6 +958,12 @@ packages: engines: {node: '>=0.4.2'} dev: true + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1133,6 +1142,12 @@ packages: '@esbuild/win32-x64': 0.21.5 dev: true + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + dev: false + /estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} dependencies: @@ -1162,6 +1177,13 @@ packages: engines: {node: '>=12.0.0'} dev: true + /extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + dependencies: + is-extendable: 0.1.1 + dev: false + /fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} dev: true @@ -1246,6 +1268,16 @@ packages: resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} dev: true + /gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + dev: false + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: true @@ -1263,6 +1295,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} dev: true + /is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + dev: false + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1282,6 +1319,14 @@ packages: hasBin: true dev: true + /js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: false + /js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1321,6 +1366,11 @@ packages: - supports-color dev: true + /kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + dev: false + /knip@5.80.0(@types/node@22.19.3)(typescript@5.9.3): resolution: {integrity: sha512-K/Ga2f/SHEUXXriVdaw2GfeIUJ5muwdqusHGkCtaG/1qeMmQJiuwZj9KnPxaDbnYPAu8RWjYYh8Nyb+qlJ3d8A==} engines: {node: '>=18.18.0'} @@ -1606,6 +1656,14 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true + /section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + dev: false + /semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -1638,6 +1696,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: false + /sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} dev: true @@ -1671,6 +1733,11 @@ packages: safe-buffer: 5.2.1 dev: true + /strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + dev: false + /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} diff --git a/src/index.ts b/src/index.ts index fd743c7..0d7a4ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ export type { JustBashLike } from "./sandbox/just-bash.js"; +export { createSkillTool } from "./skill-tool.js"; +export type { + CreateSkillToolOptions, + DiscoveredSkill, + Skill, + SkillMetadata, + SkillToolkit, +} from "./skills/types.js"; export { createBashTool } from "./tool.js"; export { DEFAULT_MAX_OUTPUT_LENGTH } from "./tools/bash.js"; export type { diff --git a/src/skill-tool.test.ts b/src/skill-tool.test.ts new file mode 100644 index 0000000..eea679a --- /dev/null +++ b/src/skill-tool.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ToolExecutionOptions } from "ai"; +import { afterEach, assert, beforeEach, describe, expect, it } from "vitest"; +import { createSkillTool } from "./skill-tool.js"; +import { createBashTool } from "./tool.js"; + +// AI SDK tool execute requires (args, options) - we provide test options +const opts: ToolExecutionOptions = { toolCallId: "test", messages: [] }; + +// Helper types for test assertions +interface LoadSkillResult { + success: boolean; + error?: string; + skill?: { name: string; description: string; path: string }; + instructions?: string; + files?: string[]; +} + +describe("createSkillTool", () => { + const testDir = "/tmp/claude/test-skill-tool"; + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("discovers skills and returns files", async () => { + const skillDir = path.join(testDir, "pdf-skill"); + await fs.mkdir(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: pdf +description: Process PDF files +--- + +# PDF Processing`, + ); + + const { loadSkill, skills, files, instructions } = await createSkillTool({ + skillsDirectory: testDir, + }); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("pdf"); + expect(skills[0].files).toContain("SKILL.md"); + expect(loadSkill).toBeDefined(); + expect(files["/skills/pdf-skill/SKILL.md"]).toContain("pdf"); + expect(instructions).toContain("pdf-skill"); + }); + + it("collects all skill files", async () => { + const skillDir = path.join(testDir, "my-skill"); + await fs.mkdir(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: my-skill +description: Test skill +---`, + ); + await fs.writeFile(path.join(skillDir, "script.py"), 'print("hello")'); + + const { files } = await createSkillTool({ skillsDirectory: testDir }); + + expect(files["/skills/my-skill/SKILL.md"]).toContain("my-skill"); + expect(files["/skills/my-skill/script.py"]).toBe('print("hello")'); + }); + + it("loadSkill returns skill instructions", async () => { + const skillDir = path.join(testDir, "test-skill"); + await fs.mkdir(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: test +description: Test skill +--- + +# Instructions + +These are the instructions.`, + ); + + const { loadSkill } = await createSkillTool({ + skillsDirectory: testDir, + }); + + assert(loadSkill.execute, "loadSkill.execute should be defined"); + const result = (await loadSkill.execute( + { skillName: "test" }, + opts, + )) as LoadSkillResult; + + expect(result.success).toBe(true); + expect(result.instructions).toContain("# Instructions"); + }); + + it("loadSkill returns error for unknown skill", async () => { + const { loadSkill } = await createSkillTool({ + skillsDirectory: testDir, + }); + + assert(loadSkill.execute, "loadSkill.execute should be defined"); + const result = (await loadSkill.execute( + { skillName: "nonexistent" }, + opts, + )) as LoadSkillResult; + + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + + it("works with empty skills directory", async () => { + const { loadSkill, skills, files, instructions } = await createSkillTool({ + skillsDirectory: testDir, + }); + + expect(skills).toHaveLength(0); + expect(loadSkill).toBeDefined(); + expect(Object.keys(files)).toHaveLength(0); + expect(instructions).toBe(""); + }); + + it("integrates with createBashTool", async () => { + const skillDir = path.join(testDir, "echo-skill"); + await fs.mkdir(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: echo +description: Echo utility +---`, + ); + await fs.writeFile( + path.join(skillDir, "test.sh"), + 'echo "hello from skill"', + ); + + // Get skill files + const { files, instructions } = await createSkillTool({ + skillsDirectory: testDir, + }); + + // Create bash tool with skill files + const { tools } = await createBashTool({ + files, + extraInstructions: instructions, + }); + + assert(tools.bash.execute, "bash.execute should be defined"); + const result = (await tools.bash.execute( + { command: "cat /workspace/skills/echo-skill/test.sh" }, + opts, + )) as { stdout: string; stderr: string; exitCode: number }; + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello from skill"); + }); +}); diff --git a/src/skill-tool.ts b/src/skill-tool.ts new file mode 100644 index 0000000..d7182e0 --- /dev/null +++ b/src/skill-tool.ts @@ -0,0 +1,118 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { discoverSkills, listSkillFiles } from "./skills/parser.js"; +import type { + CreateSkillToolOptions, + Skill, + SkillToolkit, +} from "./skills/types.js"; +import { createLoadSkillTool } from "./tools/load-skill.js"; + +const DEFAULT_DESTINATION = "/skills"; + +/** + * Creates a skill toolkit for AI agents. + * + * Skills are modular capabilities that extend agent functionality. + * Each skill is a directory containing a SKILL.md file with instructions + * and optional scripts/resources. + * + * @example + * ```typescript + * import { createSkillTool, createBashTool } from "bash-tool"; + * + * // Discover skills and get files + * const { loadSkill, skills, files, instructions } = await createSkillTool({ + * skillsDirectory: "./skills", + * }); + * + * // Create bash tool with skill files + * const { tools, sandbox } = await createBashTool({ + * files, + * extraInstructions: instructions, + * }); + * + * // Use with AI SDK + * const result = await generateText({ + * model, + * tools: { loadSkill, ...tools }, + * prompt: "Process this data using the csv skill", + * }); + * ``` + */ +export async function createSkillTool( + options: CreateSkillToolOptions, +): Promise { + const { skillsDirectory, destination = DEFAULT_DESTINATION } = options; + + // Discover all skills and collect their files + const discoveredSkills = await discoverSkills({ + skillsDirectory, + sandboxDestination: destination, + }); + + // Enrich skills with file lists and collect all files + const skills: Skill[] = []; + const allFiles: Record = {}; + + for (const skill of discoveredSkills) { + const skillFiles = await listSkillFiles(skill.localPath); + + // Add to skills with file list + skills.push({ + ...skill, + files: skillFiles, + }); + + // Read and collect all files for this skill + for (const file of skillFiles) { + const localFilePath = path.join(skill.localPath, file); + const sandboxFilePath = `${skill.sandboxPath}/${file}`; + + try { + const content = await fs.readFile(localFilePath, "utf-8"); + allFiles[sandboxFilePath] = content; + } catch { + // Skip files that can't be read as text + } + } + } + + // Create loadSkill tool + const loadSkill = createLoadSkillTool({ skills }); + + // Generate instructions for bash tool + const instructions = generateSkillInstructions(skills); + + return { + loadSkill, + skills, + files: allFiles, + instructions, + }; +} + +/** + * Generate bash tool instructions that include skill paths. + */ +function generateSkillInstructions(skills: Skill[]): string { + if (skills.length === 0) { + return ""; + } + + const lines = [ + "SKILL DIRECTORIES:", + "Skills are available at the following paths:", + ]; + + for (const skill of skills) { + lines.push(` ${skill.sandboxPath}/ - ${skill.name}: ${skill.description}`); + } + + lines.push(""); + lines.push("To use a skill:"); + lines.push(" 1. Call loadSkill to get the skill's instructions"); + lines.push(" 2. Run scripts from the skill directory with bash"); + + return lines.join("\n"); +} diff --git a/src/skills/parser.test.ts b/src/skills/parser.test.ts new file mode 100644 index 0000000..ae21464 --- /dev/null +++ b/src/skills/parser.test.ts @@ -0,0 +1,286 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + discoverSkills, + extractBody, + listSkillFiles, + parseFrontmatter, + readSkillMd, +} from "./parser.js"; + +describe("parseFrontmatter", () => { + it("parses valid frontmatter", () => { + const content = `--- +name: test-skill +description: A test skill for testing +--- + +# Test Skill + +Instructions here.`; + + const result = parseFrontmatter(content); + expect(result).toEqual({ + name: "test-skill", + description: "A test skill for testing", + }); + }); + + it("handles quoted values", () => { + const content = `--- +name: "quoted-skill" +description: 'Single quoted description' +---`; + + const result = parseFrontmatter(content); + expect(result).toEqual({ + name: "quoted-skill", + description: "Single quoted description", + }); + }); + + it("returns null for missing frontmatter", () => { + const content = "# No frontmatter here"; + const result = parseFrontmatter(content); + expect(result).toBeNull(); + }); + + it("returns null for missing required fields", () => { + const content = `--- +name: only-name +---`; + + const result = parseFrontmatter(content); + expect(result).toBeNull(); + }); + + it("ignores comments in frontmatter", () => { + const content = `--- +name: skill +# This is a comment +description: A skill +---`; + + const result = parseFrontmatter(content); + expect(result).toEqual({ + name: "skill", + description: "A skill", + }); + }); +}); + +describe("extractBody", () => { + it("extracts body after frontmatter", () => { + const content = `--- +name: test +description: test +--- + +# Instructions + +Do something.`; + + const result = extractBody(content); + expect(result).toBe("# Instructions\n\nDo something."); + }); + + it("returns full content if no frontmatter", () => { + const content = "# Just content"; + const result = extractBody(content); + expect(result).toBe("# Just content"); + }); +}); + +describe("discoverSkills", () => { + const testDir = "/tmp/claude/test-skills"; + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("discovers skills in directory", async () => { + // Create skill directories + const skillDir1 = path.join(testDir, "pdf-skill"); + const skillDir2 = path.join(testDir, "excel-skill"); + + await fs.mkdir(skillDir1); + await fs.mkdir(skillDir2); + + await fs.writeFile( + path.join(skillDir1, "SKILL.md"), + `--- +name: pdf +description: Process PDF files +--- + +# PDF Processing`, + ); + + await fs.writeFile( + path.join(skillDir2, "SKILL.md"), + `--- +name: excel +description: Work with Excel files +--- + +# Excel Processing`, + ); + + const skills = await discoverSkills({ + skillsDirectory: testDir, + sandboxDestination: "/skills", + }); + + expect(skills).toHaveLength(2); + expect(skills.map((s) => s.name).sort()).toEqual(["excel", "pdf"]); + // Check sandbox paths are set correctly + const pdfSkill = skills.find((s) => s.name === "pdf"); + expect(pdfSkill?.sandboxPath).toBe("/skills/pdf-skill"); + }); + + it("skips directories without SKILL.md", async () => { + const skillDir = path.join(testDir, "valid-skill"); + const noSkillDir = path.join(testDir, "no-skill"); + + await fs.mkdir(skillDir); + await fs.mkdir(noSkillDir); + + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: valid +description: Valid skill +---`, + ); + + // no-skill directory has no SKILL.md + + const skills = await discoverSkills({ + skillsDirectory: testDir, + sandboxDestination: "/skills", + }); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("valid"); + }); + + it("skips files in root directory", async () => { + // Create a file (not directory) in the skills directory + await fs.writeFile(path.join(testDir, "README.md"), "# Skills"); + + const skillDir = path.join(testDir, "my-skill"); + await fs.mkdir(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: my-skill +description: My skill +---`, + ); + + const skills = await discoverSkills({ + skillsDirectory: testDir, + sandboxDestination: "/skills", + }); + expect(skills).toHaveLength(1); + }); + + it("throws for non-existent directory", async () => { + await expect( + discoverSkills({ + skillsDirectory: "/nonexistent/path", + sandboxDestination: "/skills", + }), + ).rejects.toThrow(/Failed to read skills directory/); + }); +}); + +describe("readSkillMd", () => { + const testDir = "/tmp/claude/test-read-skill"; + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("reads and parses SKILL.md", async () => { + const skillMdPath = path.join(testDir, "SKILL.md"); + await fs.writeFile( + skillMdPath, + `--- +name: test-skill +description: A test skill +--- + +# Test Skill + +These are the instructions.`, + ); + + const result = await readSkillMd(skillMdPath); + + expect(result).not.toBeNull(); + expect(result?.metadata).toEqual({ + name: "test-skill", + description: "A test skill", + }); + expect(result?.body).toBe("# Test Skill\n\nThese are the instructions."); + }); + + it("returns null for non-existent file", async () => { + const result = await readSkillMd("/nonexistent/SKILL.md"); + expect(result).toBeNull(); + }); + + it("returns null for invalid SKILL.md", async () => { + const skillMdPath = path.join(testDir, "SKILL.md"); + await fs.writeFile(skillMdPath, "# No frontmatter"); + + const result = await readSkillMd(skillMdPath); + expect(result).toBeNull(); + }); +}); + +describe("listSkillFiles", () => { + const testDir = "/tmp/claude/test-list-files"; + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("lists all files in skill directory", async () => { + await fs.writeFile( + path.join(testDir, "SKILL.md"), + "---\nname: t\ndescription: t\n---", + ); + await fs.writeFile(path.join(testDir, "script.py"), "print('hello')"); + + const scriptsDir = path.join(testDir, "scripts"); + await fs.mkdir(scriptsDir); + await fs.writeFile(path.join(scriptsDir, "helper.py"), "# helper"); + + const files = await listSkillFiles(testDir); + + expect(files.sort()).toEqual([ + "SKILL.md", + "script.py", + "scripts/helper.py", + ]); + }); + + it("returns empty array for non-existent directory", async () => { + const files = await listSkillFiles("/nonexistent"); + expect(files).toEqual([]); + }); +}); diff --git a/src/skills/parser.ts b/src/skills/parser.ts new file mode 100644 index 0000000..bad6a5a --- /dev/null +++ b/src/skills/parser.ts @@ -0,0 +1,152 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import matter from "gray-matter"; +import type { DiscoveredSkill, SkillMetadata } from "./types.js"; + +/** + * Parse YAML frontmatter from SKILL.md content using gray-matter. + */ +export function parseFrontmatter(content: string): SkillMetadata | null { + try { + const { data } = matter(content); + + // Validate required fields + if ( + typeof data.name !== "string" || + typeof data.description !== "string" || + !data.name || + !data.description + ) { + return null; + } + + return { + name: data.name, + description: data.description, + }; + } catch { + return null; + } +} + +/** + * Extract the body (instructions) from SKILL.md content. + * This is everything after the frontmatter. + */ +export function extractBody(content: string): string { + try { + const { content: body } = matter(content); + return body.trim(); + } catch { + return content.trim(); + } +} + +export interface DiscoverSkillsOptions { + /** Local directory containing skill subdirectories */ + skillsDirectory: string; + /** Base path in sandbox where skills will be uploaded */ + sandboxDestination: string; +} + +/** + * Discover all skills in a directory. + * Looks for subdirectories containing SKILL.md files. + */ +export async function discoverSkills( + options: DiscoverSkillsOptions, +): Promise { + const { skillsDirectory, sandboxDestination } = options; + const skills: DiscoveredSkill[] = []; + const absoluteDir = path.resolve(skillsDirectory); + + let entries: string[]; + try { + entries = await fs.readdir(absoluteDir); + } catch (error) { + throw new Error( + `Failed to read skills directory: ${absoluteDir}. ${error instanceof Error ? error.message : String(error)}`, + ); + } + + for (const entryName of entries) { + const skillDir = path.join(absoluteDir, entryName); + + // Check if it's a directory + try { + const stat = await fs.stat(skillDir); + if (!stat.isDirectory()) continue; + } catch { + continue; + } + + const skillMdPath = path.join(skillDir, "SKILL.md"); + + try { + const content = await fs.readFile(skillMdPath, "utf-8"); + const metadata = parseFrontmatter(content); + + if (metadata) { + skills.push({ + ...metadata, + localPath: skillDir, + sandboxPath: `${sandboxDestination}/${entryName}`, + }); + } + } catch {} + } + + return skills; +} + +/** + * Read and parse a SKILL.md file, returning both metadata and body. + */ +export async function readSkillMd( + skillMdPath: string, +): Promise<{ metadata: SkillMetadata; body: string } | null> { + try { + const content = await fs.readFile(skillMdPath, "utf-8"); + const metadata = parseFrontmatter(content); + + if (!metadata) { + return null; + } + + return { + metadata, + body: extractBody(content), + }; + } catch { + return null; + } +} + +/** + * List files in a skill directory (for listing available scripts). + */ +export async function listSkillFiles(skillPath: string): Promise { + const files: string[] = []; + + async function walkDir(dir: string, prefix = ""): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + await walkDir(path.join(dir, entry.name), relativePath); + } else { + files.push(relativePath); + } + } + } + + try { + await walkDir(skillPath); + } catch { + // Return empty if directory doesn't exist + } + + return files; +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..59d5c8c --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,61 @@ +import type { createLoadSkillTool } from "../tools/load-skill.js"; + +/** + * Skill metadata parsed from SKILL.md frontmatter. + */ +export interface SkillMetadata { + /** Unique skill name (lowercase, hyphens allowed) */ + name: string; + /** Description of what the skill does and when to use it */ + description: string; +} + +/** + * Base skill info from discovery (without file list). + */ +export interface DiscoveredSkill extends SkillMetadata { + /** Absolute path to the skill directory on disk */ + localPath: string; + /** Path to the skill directory in the sandbox */ + sandboxPath: string; +} + +/** + * Full skill representation with file list. + */ +export interface Skill extends DiscoveredSkill { + /** List of files in the skill directory (relative paths) */ + files: string[]; +} + +/** + * Options for creating a skill toolkit. + */ +export interface CreateSkillToolOptions { + /** + * Path to the directory containing skill subdirectories. + * Each subdirectory should contain a SKILL.md file. + * @example "./skills" or "/path/to/skills" + */ + skillsDirectory: string; + + /** + * Base path in the sandbox where skills will be available. + * @default "/skills" + */ + destination?: string; +} + +/** + * Return type from createSkillTool. + */ +export interface SkillToolkit { + /** Tool to load a skill's instructions into context */ + loadSkill: ReturnType; + /** Registry of discovered skills */ + skills: Skill[]; + /** Files to pass to createBashTool (path -> content) */ + files: Record; + /** Extra instructions to pass to createBashTool */ + instructions: string; +} diff --git a/src/tools/load-skill.ts b/src/tools/load-skill.ts new file mode 100644 index 0000000..a55bafe --- /dev/null +++ b/src/tools/load-skill.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { tool } from "ai"; +import { z } from "zod"; +import { extractBody } from "../skills/parser.js"; +import type { Skill } from "../skills/types.js"; + +const loadSkillSchema = z.object({ + skillName: z.string().describe("The name of the skill to load"), +}); + +export interface CreateLoadSkillToolOptions { + /** Registry of discovered skills */ + skills: Skill[]; +} + +function generateDescription(skills: Skill[]): string { + const lines: string[] = [ + "Load a skill's instructions to learn how to use it.", + "You can load multiple skills - each call returns that skill's instructions.", + "", + "Available skills:", + ]; + + if (skills.length === 0) { + lines.push(" (no skills found)"); + } else { + for (const skill of skills) { + lines.push(` - ${skill.name}: ${skill.description}`); + } + } + + lines.push(""); + lines.push( + "After loading a skill, use the bash tool to run its scripts from the skill's directory.", + ); + + return lines.join("\n"); +} + +export function createLoadSkillTool(options: CreateLoadSkillToolOptions) { + const { skills } = options; + + // Create a map for quick lookup + const skillMap = new Map(); + for (const skill of skills) { + skillMap.set(skill.name, skill); + } + + return tool({ + description: generateDescription(skills), + inputSchema: loadSkillSchema, + execute: async ({ skillName }) => { + const skill = skillMap.get(skillName); + + if (!skill) { + const availableNames = skills.map((s) => s.name).join(", "); + return { + success: false, + error: `Skill "${skillName}" not found. Available skills: ${availableNames || "none"}`, + }; + } + + // Read the SKILL.md from local filesystem + const skillMdPath = path.join(skill.localPath, "SKILL.md"); + + try { + const content = await fs.readFile(skillMdPath, "utf-8"); + const body = extractBody(content); + + // Get files list (excluding SKILL.md) + const files = skill.files.filter((f) => f !== "SKILL.md"); + + return { + success: true, + skill: { + name: skill.name, + description: skill.description, + path: skill.sandboxPath, + }, + instructions: body, + files, + }; + } catch (error) { + return { + success: false, + error: `Failed to read skill "${skillName}": ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }); +} diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..95c7616 --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": null, + "noEmit": true + }, + "include": ["examples/**/*"], + "exclude": ["node_modules", "dist"] +} From aee30bf7693b8b310b65ff4398df15d597ef5ff1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 16 Jan 2026 06:43:52 -0800 Subject: [PATCH 2/8] pnpm-bs --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index ab5b59e..8ca8fff 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,5 @@ "@vercel/sandbox": { "optional": true } - }, - "packageManager": "pnpm@8.15.9+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81" + } } From 014c9774b6291c0307acb05e7c63fdacd4c2da47 Mon Sep 17 00:00:00 2001 From: Andrew Qu Date: Fri, 16 Jan 2026 11:04:46 -0800 Subject: [PATCH 3/8] correct path for skills --- src/skill-tool.test.ts | 8 ++++---- src/skill-tool.ts | 18 +++++++++++++----- src/skills/types.ts | 13 +++++++++++-- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/skill-tool.test.ts b/src/skill-tool.test.ts index eea679a..26a4216 100644 --- a/src/skill-tool.test.ts +++ b/src/skill-tool.test.ts @@ -49,8 +49,8 @@ description: Process PDF files expect(skills[0].name).toBe("pdf"); expect(skills[0].files).toContain("SKILL.md"); expect(loadSkill).toBeDefined(); - expect(files["/skills/pdf-skill/SKILL.md"]).toContain("pdf"); - expect(instructions).toContain("pdf-skill"); + expect(files["skills/pdf-skill/SKILL.md"]).toContain("pdf"); + expect(instructions).toContain("/workspace/skills/pdf-skill"); }); it("collects all skill files", async () => { @@ -67,8 +67,8 @@ description: Test skill const { files } = await createSkillTool({ skillsDirectory: testDir }); - expect(files["/skills/my-skill/SKILL.md"]).toContain("my-skill"); - expect(files["/skills/my-skill/script.py"]).toBe('print("hello")'); + expect(files["skills/my-skill/SKILL.md"]).toContain("my-skill"); + expect(files["skills/my-skill/script.py"]).toBe('print("hello")'); }); it("loadSkill returns skill instructions", async () => { diff --git a/src/skill-tool.ts b/src/skill-tool.ts index d7182e0..c56a37e 100644 --- a/src/skill-tool.ts +++ b/src/skill-tool.ts @@ -8,7 +8,8 @@ import type { } from "./skills/types.js"; import { createLoadSkillTool } from "./tools/load-skill.js"; -const DEFAULT_DESTINATION = "/skills"; +const DEFAULT_DESTINATION = "skills"; +const DEFAULT_WORKSPACE_PATH = "/workspace"; /** * Creates a skill toolkit for AI agents. @@ -43,12 +44,18 @@ const DEFAULT_DESTINATION = "/skills"; export async function createSkillTool( options: CreateSkillToolOptions, ): Promise { - const { skillsDirectory, destination = DEFAULT_DESTINATION } = options; + const { + skillsDirectory, + destination = DEFAULT_DESTINATION, + workspacePath = DEFAULT_WORKSPACE_PATH, + } = options; + + const sandboxBasePath = path.posix.join(workspacePath, destination); // Discover all skills and collect their files const discoveredSkills = await discoverSkills({ skillsDirectory, - sandboxDestination: destination, + sandboxDestination: sandboxBasePath, }); // Enrich skills with file lists and collect all files @@ -65,13 +72,14 @@ export async function createSkillTool( }); // Read and collect all files for this skill + const skillDirName = path.basename(skill.localPath); for (const file of skillFiles) { const localFilePath = path.join(skill.localPath, file); - const sandboxFilePath = `${skill.sandboxPath}/${file}`; + const relativeFilePath = path.posix.join(destination, skillDirName, file); try { const content = await fs.readFile(localFilePath, "utf-8"); - allFiles[sandboxFilePath] = content; + allFiles[relativeFilePath] = content; } catch { // Skip files that can't be read as text } diff --git a/src/skills/types.ts b/src/skills/types.ts index 59d5c8c..f39a14d 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -40,10 +40,19 @@ export interface CreateSkillToolOptions { skillsDirectory: string; /** - * Base path in the sandbox where skills will be available. - * @default "/skills" + * Relative path within the workspace where skills will be placed. + * This path is relative to createBashTool's destination. + * @default "skills" + * @example "skills" -> files at /workspace/skills/... */ destination?: string; + + /** + * The workspace path used by createBashTool. + * This is needed to generate correct absolute paths for the LLM. + * @default "/workspace" + */ + workspacePath?: string; } /** From a5f909a9d366bf82f8ed0cb4258379640682d42a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Jan 2026 18:41:52 -0800 Subject: [PATCH 4/8] Finalize --- AGENTS.npm.md | 2 +- examples/skills-tool/README.md | 5 ++++- examples/skills-tool/index.ts | 7 +++++-- src/index.ts | 2 +- src/skill-tool.test.ts | 12 ++++++------ src/skill-tool.ts | 22 ++++++++++------------ src/skills/types.ts | 10 +--------- 7 files changed, 28 insertions(+), 32 deletions(-) diff --git a/AGENTS.npm.md b/AGENTS.npm.md index 5f80bc2..da39b73 100644 --- a/AGENTS.npm.md +++ b/AGENTS.npm.md @@ -43,7 +43,7 @@ const result = await agent.generate({ ## Key Behaviors 1. **Default sandbox is just-bash** - Install `just-bash` or provide your own sandbox -2. **Working directory defaults to `./workspace`** - All files written relative to `destination` +2. **Working directory defaults to `/workspace`** - All files written relative to `destination` 3. **Files are written before tools are returned** - Sandbox is pre-populated 4. **Tool descriptions include file list** - LLM sees available files in bash tool description 5. **No `stop()` method** - Sandbox lifecycle is managed externally diff --git a/examples/skills-tool/README.md b/examples/skills-tool/README.md index c9e14fe..2e95d4e 100644 --- a/examples/skills-tool/README.md +++ b/examples/skills-tool/README.md @@ -32,7 +32,10 @@ Requires `ANTHROPIC_API_KEY` environment variable. ```typescript import { ToolLoopAgent } from "ai"; -import { createBashTool, createSkillTool } from "bash-tool"; +import { + experimental_createSkillTool as createSkillTool, + createBashTool, +} from "bash-tool"; // Discover skills and get files const { loadSkill, skills, files, instructions } = await createSkillTool({ diff --git a/examples/skills-tool/index.ts b/examples/skills-tool/index.ts index 234d203..3de0785 100644 --- a/examples/skills-tool/index.ts +++ b/examples/skills-tool/index.ts @@ -9,7 +9,10 @@ import path from "node:path"; import { ToolLoopAgent } from "ai"; -import { createBashTool, createSkillTool } from "../../src/index.js"; +import { + createBashTool, + experimental_createSkillTool as createSkillTool, +} from "../../src/index.js"; async function main() { // Discover skills and get files to upload @@ -38,7 +41,7 @@ async function main() { }, instructions: `You are a data processing assistant with access to skills. Use loadSkill to discover how to use a skill, then use bash to run its scripts. -Skills are located at /workspace/skills//.`, +Skills are located at ./skills//.`, onStepFinish: ({ toolCalls, toolResults }) => { if (toolCalls && toolCalls.length > 0) { for (const call of toolCalls) { diff --git a/src/index.ts b/src/index.ts index 0d7a4ec..9c2f733 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export type { JustBashLike } from "./sandbox/just-bash.js"; -export { createSkillTool } from "./skill-tool.js"; +export { experimental_createSkillTool } from "./skill-tool.js"; export type { CreateSkillToolOptions, DiscoveredSkill, diff --git a/src/skill-tool.test.ts b/src/skill-tool.test.ts index 26a4216..e610993 100644 --- a/src/skill-tool.test.ts +++ b/src/skill-tool.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { ToolExecutionOptions } from "ai"; import { afterEach, assert, beforeEach, describe, expect, it } from "vitest"; -import { createSkillTool } from "./skill-tool.js"; +import { experimental_createSkillTool as createSkillTool } from "./skill-tool.js"; import { createBashTool } from "./tool.js"; // AI SDK tool execute requires (args, options) - we provide test options @@ -49,8 +49,8 @@ description: Process PDF files expect(skills[0].name).toBe("pdf"); expect(skills[0].files).toContain("SKILL.md"); expect(loadSkill).toBeDefined(); - expect(files["skills/pdf-skill/SKILL.md"]).toContain("pdf"); - expect(instructions).toContain("/workspace/skills/pdf-skill"); + expect(files["./skills/pdf-skill/SKILL.md"]).toContain("pdf"); + expect(instructions).toContain("./skills/pdf-skill"); }); it("collects all skill files", async () => { @@ -67,8 +67,8 @@ description: Test skill const { files } = await createSkillTool({ skillsDirectory: testDir }); - expect(files["skills/my-skill/SKILL.md"]).toContain("my-skill"); - expect(files["skills/my-skill/script.py"]).toBe('print("hello")'); + expect(files["./skills/my-skill/SKILL.md"]).toContain("my-skill"); + expect(files["./skills/my-skill/script.py"]).toBe('print("hello")'); }); it("loadSkill returns skill instructions", async () => { @@ -154,7 +154,7 @@ description: Echo utility assert(tools.bash.execute, "bash.execute should be defined"); const result = (await tools.bash.execute( - { command: "cat /workspace/skills/echo-skill/test.sh" }, + { command: "cat ./skills/echo-skill/test.sh" }, opts, )) as { stdout: string; stderr: string; exitCode: number }; diff --git a/src/skill-tool.ts b/src/skill-tool.ts index c56a37e..f8acc30 100644 --- a/src/skill-tool.ts +++ b/src/skill-tool.ts @@ -9,7 +9,6 @@ import type { import { createLoadSkillTool } from "./tools/load-skill.js"; const DEFAULT_DESTINATION = "skills"; -const DEFAULT_WORKSPACE_PATH = "/workspace"; /** * Creates a skill toolkit for AI agents. @@ -20,7 +19,10 @@ const DEFAULT_WORKSPACE_PATH = "/workspace"; * * @example * ```typescript - * import { createSkillTool, createBashTool } from "bash-tool"; + * import { + * experimental_createSkillTool as createSkillTool, + * createBashTool, + * } from "bash-tool"; * * // Discover skills and get files * const { loadSkill, skills, files, instructions } = await createSkillTool({ @@ -41,21 +43,17 @@ const DEFAULT_WORKSPACE_PATH = "/workspace"; * }); * ``` */ -export async function createSkillTool( +export async function experimental_createSkillTool( options: CreateSkillToolOptions, ): Promise { - const { - skillsDirectory, - destination = DEFAULT_DESTINATION, - workspacePath = DEFAULT_WORKSPACE_PATH, - } = options; - - const sandboxBasePath = path.posix.join(workspacePath, destination); + const { skillsDirectory, destination = DEFAULT_DESTINATION } = options; // Discover all skills and collect their files + // sandboxDestination uses explicit relative path (e.g., "./skills") - works with any destination + const relativeDestination = `./${destination}`; const discoveredSkills = await discoverSkills({ skillsDirectory, - sandboxDestination: sandboxBasePath, + sandboxDestination: relativeDestination, }); // Enrich skills with file lists and collect all files @@ -75,7 +73,7 @@ export async function createSkillTool( const skillDirName = path.basename(skill.localPath); for (const file of skillFiles) { const localFilePath = path.join(skill.localPath, file); - const relativeFilePath = path.posix.join(destination, skillDirName, file); + const relativeFilePath = `./${path.posix.join(destination, skillDirName, file)}`; try { const content = await fs.readFile(localFilePath, "utf-8"); diff --git a/src/skills/types.ts b/src/skills/types.ts index f39a14d..2ac4bf0 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -41,18 +41,10 @@ export interface CreateSkillToolOptions { /** * Relative path within the workspace where skills will be placed. - * This path is relative to createBashTool's destination. * @default "skills" - * @example "skills" -> files at /workspace/skills/... + * @example "skills" -> files at ./skills/... */ destination?: string; - - /** - * The workspace path used by createBashTool. - * This is needed to generate correct absolute paths for the LLM. - * @default "/workspace" - */ - workspacePath?: string; } /** From dc10bf882bde91da6c8157d6c07b61c73ffd55d9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Jan 2026 18:57:45 -0800 Subject: [PATCH 5/8] Document --- README.md | 70 ++++++++++++++++++++++++++++++++++-- src/readme.test.ts | 90 ++++++++++++---------------------------------- src/tools/bash.ts | 4 +-- src/types.ts | 8 ++--- 4 files changed, 97 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 6bb9917..8611bc2 100644 --- a/README.md +++ b/README.md @@ -167,14 +167,80 @@ const customSandbox: Sandbox = { // Your implementation here return ""; }, - async writeFile(path, content) { - // Your implementation here + async writeFiles(files) { + // Your implementation here - files is Array<{path, content}> }, }; const { tools } = await createBashTool({ sandbox: customSandbox }); ``` +## Skills (Experimental) + +Skills are modular capabilities that extend agent functionality. Each skill is a directory containing a `SKILL.md` file with instructions and optional scripts. + +```typescript +import { + experimental_createSkillTool as createSkillTool, + createBashTool, +} from "bash-tool"; +import { ToolLoopAgent } from "ai"; + +// Discover skills and get files to upload +const { loadSkill, files, instructions } = await createSkillTool({ + skillsDirectory: "./skills", +}); + +// Create bash tool with skill files +const { tools } = await createBashTool({ + files, + extraInstructions: instructions, +}); + +// Use both tools with an agent +const agent = new ToolLoopAgent({ + model, + tools: { loadSkill, ...tools }, +}); +``` + +### Skill Directory Structure + +``` +skills/ +├── csv/ +│ ├── SKILL.md # Required: instructions with YAML frontmatter +│ ├── analyze.sh # Optional: scripts the agent can run +│ └── filter.sh +└── text/ + ├── SKILL.md + └── search.sh +``` + +### SKILL.md Format + +```markdown +--- +name: csv +description: Analyze and transform CSV files +--- + +# CSV Processing + +Use `./skills/csv/analyze.sh ` to analyze a CSV file. +``` + +### How It Works + +1. `createSkillTool` discovers skills and returns: + - `loadSkill` - Tool for the agent to load a skill's instructions on demand + - `files` - All skill files to pass to `createBashTool` + - `instructions` - Extra instructions listing available skills + +2. The agent sees skill names in the `loadSkill` tool description +3. When the agent needs a skill, it calls `loadSkill("csv")` to get detailed instructions +4. The agent uses `bash` to run scripts from `./skills/csv/` + ## AI Agent Instructions For AI agents working with bash-tool, additional guidance is available in `AGENTS.md`: diff --git a/src/readme.test.ts b/src/readme.test.ts index f81ece4..f4f51f9 100644 --- a/src/readme.test.ts +++ b/src/readme.test.ts @@ -48,8 +48,8 @@ function createTempProject(codeBlocks: string[]): string { lib: ["ES2022"], typeRoots: [join(REPO_ROOT, "node_modules/@types")], paths: { - // Use .d.ts stubs to avoid pulling in full source - "bash-tool": [join(tempDir, "bash-tool.d.ts")], + // Use actual built types for bash-tool, stubs for external packages + "bash-tool": [join(REPO_ROOT, "dist/index.d.ts")], ai: [join(tempDir, "ai.d.ts")], "@vercel/sandbox": [join(tempDir, "vercel-sandbox.d.ts")], "just-bash": [join(tempDir, "just-bash.d.ts")], @@ -63,59 +63,6 @@ function createTempProject(codeBlocks: string[]): string { JSON.stringify(tsconfig, null, 2), ); - // Create stub for bash-tool - writeFileSync( - join(tempDir, "bash-tool.d.ts"), - ` -export interface CommandResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export interface Sandbox { - executeCommand(command: string): Promise; - readFile(path: string): Promise; - writeFile(path: string, content: string): Promise; -} - -export interface BeforeBashCallInput { - command: string; -} - -export interface BeforeBashCallOutput { - command: string; -} - -export interface AfterBashCallInput { - command: string; - result: CommandResult; -} - -export interface AfterBashCallOutput { - result: CommandResult; -} - -export interface CreateBashToolOptions { - destination?: string; - files?: Record; - uploadDirectory?: { source: string; include?: string }; - sandbox?: any; - extraInstructions?: string; - onBeforeBashCall?: (input: BeforeBashCallInput) => BeforeBashCallOutput | void; - onAfterBashCall?: (input: AfterBashCallInput) => AfterBashCallOutput | void; -} - -export interface BashToolkit { - bash: any; - tools: Record; - sandbox: Sandbox; -} - -export function createBashTool(options?: CreateBashToolOptions): Promise; -`, - ); - // Create stub for ai writeFileSync( join(tempDir, "ai.d.ts"), @@ -156,6 +103,11 @@ export class Sandbox { ` export class Bash { constructor(opts?: { cwd?: string }); + exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }>; + fs: { + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + }; } `, ); @@ -175,15 +127,19 @@ export class Bash { // Build assumed imports based on what the code block uses const assumedImports: string[] = []; - if (code.includes("createBashTool")) { - // Check if code imports Sandbox from bash-tool (for custom implementation) + if (code.includes("createBashTool") || code.includes("createSkillTool")) { + // Build bash-tool import based on what's used + const imports: string[] = []; + if (code.includes("createBashTool")) { + imports.push("createBashTool"); + } + if (code.includes("createSkillTool")) { + imports.push("experimental_createSkillTool as createSkillTool"); + } if (code.includes('from "bash-tool"') && code.includes("Sandbox")) { - assumedImports.push( - 'import { createBashTool, Sandbox } from "bash-tool";', - ); - } else { - assumedImports.push('import { createBashTool } from "bash-tool";'); + imports.push("Sandbox"); } + assumedImports.push(`import { ${imports.join(", ")} } from "bash-tool";`); } if (code.includes("ToolLoopAgent") || code.includes("stepCountIs")) { assumedImports.push('import { ToolLoopAgent, stepCountIs } from "ai";'); @@ -207,11 +163,11 @@ declare const yourModel: any; declare const model: any; `; - // Strip existing imports from the code - const codeWithoutImports = code - .split("\n") - .filter((line) => !line.trim().startsWith("import ")) - .join("\n"); + // Strip existing imports from the code (handles multi-line imports) + const codeWithoutImports = code.replace( + /^import\s+[\s\S]*?from\s+["'][^"']+["'];?\s*$/gm, + "", + ); // Wrap code in async IIFE to allow top-level await and isolate scope const wrappedCode = ` diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 813cb41..e1839be 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -27,11 +27,11 @@ export interface CreateBashToolOptions { /** Callback before command execution, can modify the command */ onBeforeBashCall?: ( input: BeforeBashCallInput, - ) => BeforeBashCallOutput | undefined; + ) => BeforeBashCallOutput | void; /** Callback after command execution, can modify the result */ onAfterBashCall?: ( input: AfterBashCallInput, - ) => AfterBashCallOutput | undefined; + ) => AfterBashCallOutput | void; /** * Maximum length (in characters) for stdout and stderr output. * If output exceeds this limit, it will be truncated with a message. diff --git a/src/types.ts b/src/types.ts index 8f60305..cbb85c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,7 @@ export interface BeforeBashCallInput { /** * Output from onBeforeBashCall callback. - * Return undefined or the original input to proceed unchanged. + * Return nothing to proceed unchanged. */ export interface BeforeBashCallOutput { /** The (potentially modified) command to execute */ @@ -44,7 +44,7 @@ export interface AfterBashCallInput { /** * Output from onAfterBashCall callback. - * Return undefined or the original result to proceed unchanged. + * Return nothing to proceed unchanged. */ export interface AfterBashCallOutput { /** The (potentially modified) result */ @@ -123,7 +123,7 @@ export interface CreateBashToolOptions { */ onBeforeBashCall?: ( input: BeforeBashCallInput, - ) => BeforeBashCallOutput | undefined; + ) => BeforeBashCallOutput | void; /** * Callback invoked after bash command execution. @@ -140,7 +140,7 @@ export interface CreateBashToolOptions { */ onAfterBashCall?: ( input: AfterBashCallInput, - ) => AfterBashCallOutput | undefined; + ) => AfterBashCallOutput | void; /** * Maximum length (in characters) for stdout and stderr output. From 605f4586a5c2571040dad4e81f19a9f50e86bd28 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Jan 2026 19:01:45 -0800 Subject: [PATCH 6/8] lint --- AGENTS.npm.md | 4 ++-- src/tools/bash.ts | 4 ++-- src/types.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.npm.md b/AGENTS.npm.md index da39b73..e12f7df 100644 --- a/AGENTS.npm.md +++ b/AGENTS.npm.md @@ -88,11 +88,11 @@ const { tools } = await createBashTool({ sandbox: existingSandbox }); const { tools } = await createBashTool({ onBeforeBashCall: ({ command }) => { console.log("Running:", command); - // Return modified command or undefined to proceed unchanged + return undefined; // Or return { command: modifiedCommand } to change it }, onAfterBashCall: ({ command, result }) => { console.log(`Exit: ${result.exitCode}`); - // Return modified result or undefined to proceed unchanged + return undefined; // Or return { result: modifiedResult } to change it }, }); ``` diff --git a/src/tools/bash.ts b/src/tools/bash.ts index e1839be..813cb41 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -27,11 +27,11 @@ export interface CreateBashToolOptions { /** Callback before command execution, can modify the command */ onBeforeBashCall?: ( input: BeforeBashCallInput, - ) => BeforeBashCallOutput | void; + ) => BeforeBashCallOutput | undefined; /** Callback after command execution, can modify the result */ onAfterBashCall?: ( input: AfterBashCallInput, - ) => AfterBashCallOutput | void; + ) => AfterBashCallOutput | undefined; /** * Maximum length (in characters) for stdout and stderr output. * If output exceeds this limit, it will be truncated with a message. diff --git a/src/types.ts b/src/types.ts index cbb85c2..37a7824 100644 --- a/src/types.ts +++ b/src/types.ts @@ -123,7 +123,7 @@ export interface CreateBashToolOptions { */ onBeforeBashCall?: ( input: BeforeBashCallInput, - ) => BeforeBashCallOutput | void; + ) => BeforeBashCallOutput | undefined; /** * Callback invoked after bash command execution. @@ -140,7 +140,7 @@ export interface CreateBashToolOptions { */ onAfterBashCall?: ( input: AfterBashCallInput, - ) => AfterBashCallOutput | void; + ) => AfterBashCallOutput | undefined; /** * Maximum length (in characters) for stdout and stderr output. From 3486d2e0587f8d4c614410cc789b1bf3faa5445e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Jan 2026 19:05:28 -0800 Subject: [PATCH 7/8] build --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8ca8fff..c6a3171 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "lint:fix": "biome check --write .", "format": "biome format --write .", "knip": "knip", - "validate": "pnpm run lint && pnpm run knip && pnpm run typecheck && pnpm run test:run", + "validate": "pnpm run lint && pnpm run knip && pnpm run typecheck && pnpm run build && pnpm run test:run", "prepublishOnly": "pnpm run validate && pnpm run build" }, "keywords": [ From 818d7b212439b8f3e5a4d5d1e3974f45029331fb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Jan 2026 19:06:40 -0800 Subject: [PATCH 8/8] build --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a17f7d9..c44b586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,10 @@ jobs: - name: Type check run: pnpm typecheck + - name: Build + run: pnpm build + - name: Test run: pnpm test:run - - name: Build - run: pnpm build +