📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
@@ -0,0 +1,145 @@
#!/usr/bin/env bun
/**
* detect-skill-context.ts
*
* Detects the execution context for a skill based on its file path.
* Returns JSON with context type, reason, and recommendations.
*
* Usage:
* bun detect-skill-context.ts /path/to/SKILL.md
* bun detect-skill-context.ts --check # Check current directory
*/
import { basename, dirname } from "path";
/**
* Result of skill context detection.
*/
interface ContextResult {
/** Detected platform context */
context: "claude" | "codex" | "cursor" | "github" | "generic";
/** Explanation for why this context was detected */
reason: string;
/** Platform-specific recommendations */
recommendations: string[];
/** Normalized file path that was analyzed */
path: string;
}
const CONTEXT_PATTERNS: Record<
string,
{ patterns: RegExp[]; context: ContextResult["context"]; reason: string }
> = {
claude: {
patterns: [
/\.claude-plugin\//,
/\.claude\/skills\//,
/\/\.claude\/skills\//,
/~\/\.claude\/skills\//,
],
context: "claude",
reason: "Path contains Claude Code skill location",
},
codex: {
patterns: [/\.codex\/skills\//, /codex-skills\//, /\.codex\//],
context: "codex",
reason: "Path contains Codex CLI skill location",
},
cursor: {
patterns: [/\.cursor\/skills\//, /cursor-rules\//],
context: "cursor",
reason: "Path contains Cursor skill location",
},
github: {
patterns: [/\.github\/skills\//, /\.github\/copilot\//],
context: "github",
reason: "Path contains GitHub Copilot skill location",
},
};
const RECOMMENDATIONS: Record<string, string[]> = {
claude: [
"Consider adding 'allowed-tools' for tool permissions",
"Use 'argument-hint' if skill accepts arguments",
"Test with 'claude --debug' to verify loading",
],
codex: [
"Skills are invoked with $skill-name syntax",
"Check codex discovery paths are configured",
],
cursor: [
"Cursor uses .cursorrules for project-level instructions",
"Skills integrate via cursor-rules format",
],
github: [
"GitHub Copilot skills follow repository patterns",
"Check .github/copilot-instructions.md for integration",
],
generic: [
"Stick to base spec fields: name, description, version, license, compatibility, metadata",
"Platform-specific fields should be under metadata",
"See https://agentskills.io/specification for cross-platform guidance",
],
};
/**
* Detects the execution context for a skill based on its file path.
* @param path - Path to the SKILL.md file
* @returns Context detection result with platform and recommendations
*/
function detectContext(path: string): ContextResult {
const normalizedPath = path.replace(/\\/g, "/");
for (const [, config] of Object.entries(CONTEXT_PATTERNS)) {
for (const pattern of config.patterns) {
if (pattern.test(normalizedPath)) {
return {
context: config.context,
reason: config.reason,
recommendations: RECOMMENDATIONS[config.context] || [],
path: normalizedPath,
};
}
}
}
// Generic context - no specific platform detected
return {
context: "generic",
reason: "No platform-specific path pattern detected",
recommendations: RECOMMENDATIONS.generic,
path: normalizedPath,
};
}
function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`Usage: detect-skill-context.ts <path-to-SKILL.md>
Detects execution context for skills based on file path.
Options:
--check Check current directory for SKILL.md
--help Show this help message
Output: JSON with context, reason, recommendations, and path
`);
process.exit(0);
}
let targetPath: string;
if (args[0] === "--check") {
// Look for SKILL.md in current directory
targetPath = `${process.cwd()}/SKILL.md`;
} else {
targetPath = args[0];
}
const result = detectContext(targetPath);
console.log(JSON.stringify(result, null, 2));
}
main();
+588
View File
@@ -0,0 +1,588 @@
#!/usr/bin/env bash
# init-plugin.sh - Interactive plugin initialization wizard
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Print colored output
print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
print_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
print_prompt() { echo -e "${CYAN}[?]${NC} $1"; }
# Usage information
usage() {
cat << EOF
Usage: $0 [options]
Interactive wizard to initialize a new Claude Code plugin.
Options:
-d, --directory DIR Create plugin in specified directory (default: current)
-n, --non-interactive Use defaults without prompting
-h, --help Show this help message
Example:
$0 # Interactive mode
$0 -d ./plugins # Create in specific directory
$0 -n # Non-interactive with defaults
EOF
exit 1
}
# Parse arguments
PLUGIN_DIR="."
INTERACTIVE=true
while [[ $# -gt 0 ]]; do
case $1 in
-d|--directory)
PLUGIN_DIR="$2"
shift 2
;;
-n|--non-interactive)
INTERACTIVE=false
shift
;;
-h|--help)
usage
;;
-*)
print_error "Unknown option: $1"
usage
;;
*)
print_error "Unexpected argument: $1"
usage
;;
esac
done
# Prompt for input with default
prompt_with_default() {
local prompt="$1"
local default="$2"
local result
if [[ "$INTERACTIVE" == "false" ]]; then
echo "$default"
return
fi
read -p "$(echo -e "${CYAN}[?]${NC} ${prompt} [${default}]: ")" result
echo "${result:-$default}"
}
# Prompt yes/no with default
prompt_yes_no() {
local prompt="$1"
local default="$2"
local result
if [[ "$INTERACTIVE" == "false" ]]; then
echo "$default"
return
fi
while true; do
read -p "$(echo -e "${CYAN}[?]${NC} ${prompt} (y/n) [${default}]: ")" result
result="${result:-$default}"
case "$result" in
y|Y|yes|Yes|YES)
echo "y"
return
;;
n|N|no|No|NO)
echo "n"
return
;;
*)
print_warn "Please answer y or n"
;;
esac
done
}
# Validate plugin name
validate_plugin_name() {
local name="$1"
if [[ -z "$name" ]]; then
print_error "Plugin name cannot be empty"
return 1
fi
if [[ ! "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then
print_error "Plugin name must be kebab-case (lowercase letters, numbers, hyphens only)"
print_error "Examples: my-plugin, dev-tools, claude-helper"
return 1
fi
return 0
}
# Banner
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Claude Code Plugin Initialization ║${NC}"
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
echo
# Step 1: Plugin name
print_step "Plugin Information"
echo
while true; do
PLUGIN_NAME=$(prompt_with_default "Plugin name (kebab-case)" "my-plugin")
if validate_plugin_name "$PLUGIN_NAME"; then
break
fi
done
PLUGIN_PATH="$PLUGIN_DIR/$PLUGIN_NAME"
# Check if directory exists
if [[ -d "$PLUGIN_PATH" ]]; then
print_error "Directory already exists: $PLUGIN_PATH"
exit 1
fi
# Step 2: Plugin metadata
DESCRIPTION=$(prompt_with_default "Plugin description" "A Claude Code plugin")
VERSION=$(prompt_with_default "Initial version" "0.1.0")
LICENSE=$(prompt_with_default "License" "MIT")
# Get author info from git config or prompt
GIT_USER=$(git config user.name 2>/dev/null || echo "")
GIT_EMAIL=$(git config user.email 2>/dev/null || echo "")
AUTHOR_NAME=$(prompt_with_default "Author name" "${GIT_USER:-Author Name}")
AUTHOR_EMAIL=$(prompt_with_default "Author email" "${GIT_EMAIL:-author@example.com}")
# Step 3: Plugin components
echo
print_step "Plugin Components"
echo
print_info "Select which components to include:"
echo
WITH_SKILLS=$(prompt_yes_no "Include skills?" "n")
WITH_COMMANDS=$(prompt_yes_no "Include slash commands?" "n")
WITH_AGENTS=$(prompt_yes_no "Include custom agents?" "n")
WITH_HOOKS=$(prompt_yes_no "Include event hooks?" "n")
WITH_MCP=$(prompt_yes_no "Include MCP server?" "n")
# Step 4: Repository setup
echo
print_step "Repository Configuration"
echo
INIT_GIT=$(prompt_yes_no "Initialize git repository?" "y")
CREATE_GITHUB_ACTIONS=$(prompt_yes_no "Add GitHub Actions workflow?" "n")
# Step 5: Confirmation
echo
print_step "Summary"
echo
echo "Plugin Name: $PLUGIN_NAME"
echo "Location: $PLUGIN_PATH"
echo "Description: $DESCRIPTION"
echo "Version: $VERSION"
echo "License: $LICENSE"
echo "Author: $AUTHOR_NAME <$AUTHOR_EMAIL>"
echo
echo "Components:"
[[ "$WITH_SKILLS" == "y" ]] && echo " ✓ Skills"
[[ "$WITH_COMMANDS" == "y" ]] && echo " ✓ Slash Commands"
[[ "$WITH_AGENTS" == "y" ]] && echo " ✓ Custom Agents"
[[ "$WITH_HOOKS" == "y" ]] && echo " ✓ Event Hooks"
[[ "$WITH_MCP" == "y" ]] && echo " ✓ MCP Server"
echo
if [[ "$INTERACTIVE" == "true" ]]; then
CONFIRM=$(prompt_yes_no "Create plugin?" "y")
if [[ "$CONFIRM" != "y" ]]; then
print_warn "Cancelled"
exit 0
fi
fi
# Create plugin structure
echo
print_info "Creating plugin structure..."
mkdir -p "$PLUGIN_PATH/.claude-plugin"
# Create plugin.json
print_info "Creating plugin.json"
cat > "$PLUGIN_PATH/.claude-plugin/plugin.json" << EOF
{
"name": "$PLUGIN_NAME",
"version": "$VERSION",
"description": "$DESCRIPTION",
"author": {
"name": "$AUTHOR_NAME",
"email": "$AUTHOR_EMAIL"
},
"license": "$LICENSE",
"keywords": []
}
EOF
# Create README.md
print_info "Creating README.md"
cat > "$PLUGIN_PATH/README.md" << EOF
# $PLUGIN_NAME
$DESCRIPTION
## Installation
\`\`\`bash
# Add marketplace
/plugin marketplace add <path-or-repo>
# Install plugin
/plugin install $PLUGIN_NAME@$PLUGIN_NAME
\`\`\`
## Features
TODO: List your plugin features
## Usage
TODO: Describe how to use your plugin
## Development
TODO: Add development instructions
## License
$LICENSE License - see [LICENSE](LICENSE) for details.
EOF
# Create CHANGELOG.md
print_info "Creating CHANGELOG.md"
cat > "$PLUGIN_PATH/CHANGELOG.md" << EOF
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [$VERSION] - $(date +%Y-%m-%d)
### Added
- Initial release
EOF
# Create LICENSE
print_info "Creating LICENSE"
if [[ "$LICENSE" == "MIT" ]]; then
cat > "$PLUGIN_PATH/LICENSE" << EOF
MIT License
Copyright (c) $(date +%Y) $AUTHOR_NAME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
EOF
else
touch "$PLUGIN_PATH/LICENSE"
print_warn "Please add $LICENSE license text to LICENSE file"
fi
# Create .gitignore
cat > "$PLUGIN_PATH/.gitignore" << 'EOF'
# Logs
*.log
logs/
# OS files
.DS_Store
Thumbs.db
# Editor directories
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
# Dependencies
node_modules/
__pycache__/
*.pyc
.venv/
venv/
dist/
build/
# Test coverage
coverage/
.coverage
*.cover
EOF
# Create components based on selections
if [[ "$WITH_SKILLS" == "y" ]]; then
print_info "Creating skills structure"
mkdir -p "$PLUGIN_PATH/skills/example-skill"
cat > "$PLUGIN_PATH/skills/example-skill/SKILL.md" << 'EOF'
---
name: example-skill
description: An example skill that demonstrates skill authoring
version: 0.1.0
---
# Example Skill
This is an example skill. Skills are automatically activated based on:
- Keywords in the description
- User mentioning the skill name
- Context matching skill capabilities
## What This Skill Does
Describe what this skill helps with.
## When to Use
This skill activates when:
- Condition 1
- Condition 2
- Condition 3
## Quick Start
Provide a quick example of using this skill.
## Detailed Guide
Add comprehensive documentation here.
EOF
print_info "Add skills to plugin.json manually or use the skill authoring tools"
fi
if [[ "$WITH_COMMANDS" == "y" ]]; then
print_info "Creating commands structure"
mkdir -p "$PLUGIN_PATH/commands"
cat > "$PLUGIN_PATH/commands/hello.md" << 'EOF'
---
description: "Say hello with a friendly greeting"
---
Generate a friendly greeting for {{0:name}}.
Make the greeting:
- Warm and welcoming
- Include time of day
- Professional yet friendly
EOF
fi
if [[ "$WITH_AGENTS" == "y" ]]; then
print_info "Creating agents structure"
mkdir -p "$PLUGIN_PATH/agents"
cat > "$PLUGIN_PATH/agents/helper.md" << 'EOF'
---
name: helper
description: "A helpful assistant for common tasks"
---
You are a helpful assistant specialized in [domain].
Your responsibilities:
1. Task 1
2. Task 2
3. Task 3
Guidelines:
- Be clear and concise
- Provide examples
- Explain your reasoning
EOF
# Update plugin.json
tmp=$(mktemp)
jq '.agents = ["./agents/helper.md"]' "$PLUGIN_PATH/.claude-plugin/plugin.json" > "$tmp"
mv "$tmp" "$PLUGIN_PATH/.claude-plugin/plugin.json"
fi
if [[ "$WITH_HOOKS" == "y" ]]; then
print_info "Creating hooks structure"
mkdir -p "$PLUGIN_PATH/hooks"
cat > "$PLUGIN_PATH/hooks/example-hook.sh" << 'EOF'
#!/usr/bin/env bash
# Example hook - receives JSON on stdin, outputs JSON to stdout
input=$(cat)
# Parse input
file_path=$(echo "$input" | jq -r '.parameters.file_path // empty')
# Add your logic here
# For PreToolUse hooks, you can approve/block:
# echo '{"allowed": true}'
# echo '{"allowed": false, "reason": "validation failed"}'
# For PostToolUse hooks, you can modify the result:
# echo "$input" | jq '.result.modified = true'
# Default: allow the operation
echo '{"allowed": true}'
EOF
chmod +x "$PLUGIN_PATH/hooks/example-hook.sh"
print_info "Add hooks to plugin.json manually or use the hook authoring tools"
fi
if [[ "$WITH_MCP" == "y" ]]; then
print_info "Creating MCP server structure"
mkdir -p "$PLUGIN_PATH/servers/$PLUGIN_NAME-server"
cat > "$PLUGIN_PATH/servers/$PLUGIN_NAME-server/server.py" << EOF
"""MCP server for $PLUGIN_NAME"""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("$PLUGIN_NAME")
@mcp.tool()
async def example_tool(param: str) -> str:
"""
Example tool implementation.
Args:
param: Parameter description
Returns:
Result description
"""
return f"Processed: {param}"
if __name__ == "__main__":
mcp.run(transport='stdio')
EOF
cat > "$PLUGIN_PATH/servers/$PLUGIN_NAME-server/pyproject.toml" << EOF
[project]
name = "$PLUGIN_NAME-server"
version = "$VERSION"
description = "MCP server for $PLUGIN_NAME"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.2.0",
]
EOF
# Update plugin.json
tmp=$(mktemp)
jq --arg name "$PLUGIN_NAME" '.mcpServers = {($name): {"command": "uv", "args": ["--directory", "${CLAUDE_PLUGIN_ROOT}/servers/\($name)-server", "run", "server.py"]}}' "$PLUGIN_PATH/.claude-plugin/plugin.json" > "$tmp"
mv "$tmp" "$PLUGIN_PATH/.claude-plugin/plugin.json"
fi
# Create GitHub Actions workflow if requested
if [[ "$CREATE_GITHUB_ACTIONS" == "y" ]]; then
print_info "Creating GitHub Actions workflow"
mkdir -p "$PLUGIN_PATH/.github/workflows"
cat > "$PLUGIN_PATH/.github/workflows/validate.yml" << 'EOF'
name: Validate Plugin
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate plugin.json
run: |
# Add validation logic here
if [ ! -f ".claude-plugin/plugin.json" ]; then
echo "Error: plugin.json not found"
exit 1
fi
# Validate JSON syntax
jq empty .claude-plugin/plugin.json
EOF
fi
# Initialize git repository
if [[ "$INIT_GIT" == "y" ]] && command -v git &> /dev/null; then
print_info "Initializing git repository"
cd "$PLUGIN_PATH"
git init -q
git add .
git commit -q -m "feat: initial plugin structure
- Generated with outfitter init-plugin.sh
- Initialized $PLUGIN_NAME v$VERSION"
cd - > /dev/null
fi
# Success message
echo
print_info "✓ Plugin created successfully!"
echo
echo -e "${BLUE}Plugin Location:${NC} $PLUGIN_PATH"
echo
echo -e "${YELLOW}Next Steps:${NC}"
echo " 1. cd $PLUGIN_PATH"
echo " 2. Edit .claude-plugin/plugin.json to update metadata"
echo " 3. Update README.md with plugin details"
[[ "$WITH_SKILLS" == "y" ]] && echo " 4. Customize skills in skills/"
[[ "$WITH_COMMANDS" == "y" ]] && echo " 4. Add commands to commands/"
[[ "$WITH_AGENTS" == "y" ]] && echo " 4. Customize agents in agents/"
[[ "$WITH_HOOKS" == "y" ]] && echo " 4. Implement hooks in hooks/"
[[ "$WITH_MCP" == "y" ]] && echo " 4. Implement MCP server in servers/"
echo
echo -e "${YELLOW}Test Locally:${NC}"
echo " /plugin marketplace add $PLUGIN_PATH"
echo " /plugin install $PLUGIN_NAME@$PLUGIN_NAME"
echo
print_info "Happy coding!"
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env bash
# test-plugin.sh - Test Claude Code plugin locally
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Print functions
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# Cleanup flag
CLEANUP_ON_EXIT=true
TEST_MARKETPLACE_DIR=""
NON_INTERACTIVE=false
# Cleanup function
cleanup() {
if [[ "$CLEANUP_ON_EXIT" == "true" && -n "$TEST_MARKETPLACE_DIR" && -d "$TEST_MARKETPLACE_DIR" ]]; then
print_info "Cleaning up test marketplace: $TEST_MARKETPLACE_DIR"
rm -rf "$TEST_MARKETPLACE_DIR"
fi
}
trap cleanup EXIT
# Usage
usage() {
cat << EOF
Usage: $0 [options] <plugin-directory>
Test a Claude Code plugin locally before distribution.
Arguments:
plugin-directory Path to plugin root directory
Options:
-k, --keep-temp Keep temporary marketplace directory
-v, --validate Run validation before testing
-n, --non-interactive Skip interactive prompts (for CI/automated use)
-h, --help Show this help
Examples:
# Test current plugin
$0 .
# Test and keep marketplace
$0 --keep-temp /path/to/my-plugin
# Validate and test
$0 --validate .
What This Script Does:
1. Creates a temporary local marketplace
2. Adds your plugin to the marketplace
3. Provides instructions for testing
4. Cleans up temporary files (unless --keep-temp)
Note: This script prepares the test environment but does NOT
install the plugin automatically. You'll need to run
the Claude Code plugin commands manually to test.
EOF
exit 0
}
# Parse arguments
PLUGIN_DIR=""
RUN_VALIDATION=false
while [[ $# -gt 0 ]]; do
case $1 in
-k|--keep-temp)
CLEANUP_ON_EXIT=false
shift
;;
-v|--validate)
RUN_VALIDATION=true
shift
;;
-n|--non-interactive)
NON_INTERACTIVE=true
shift
;;
-h|--help)
usage
;;
-*)
print_error "Unknown option: $1"
usage
;;
*)
PLUGIN_DIR="$1"
shift
;;
esac
done
# Validate arguments
if [[ -z "$PLUGIN_DIR" ]]; then
print_error "Plugin directory required"
usage
fi
if [[ ! -d "$PLUGIN_DIR" ]]; then
print_error "Directory not found: $PLUGIN_DIR"
exit 1
fi
# Convert to absolute path
PLUGIN_DIR=$(cd "$PLUGIN_DIR" && pwd)
# Check for plugin.json
PLUGIN_JSON="$PLUGIN_DIR/.claude-plugin/plugin.json"
if [[ ! -f "$PLUGIN_JSON" ]]; then
print_error "Not a valid plugin: .claude-plugin/plugin.json not found"
exit 1
fi
# Extract plugin info
PLUGIN_NAME=$(jq -r '.name // empty' "$PLUGIN_JSON")
PLUGIN_VERSION=$(jq -r '.version // empty' "$PLUGIN_JSON")
if [[ -z "$PLUGIN_NAME" ]]; then
print_error "Plugin name not found in plugin.json"
exit 1
fi
# Print header
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Claude Code Plugin Testing ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}"
echo
echo -e "${CYAN}Plugin:${NC} $PLUGIN_NAME"
echo -e "${CYAN}Version:${NC} $PLUGIN_VERSION"
echo -e "${CYAN}Path:${NC} $PLUGIN_DIR"
echo
# Step 1: Optional validation
if [[ "$RUN_VALIDATION" == "true" ]]; then
print_step "Running validation"
echo
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VALIDATE_SCRIPT="$SCRIPT_DIR/validate-plugin.sh"
if [[ -f "$VALIDATE_SCRIPT" ]]; then
if ! "$VALIDATE_SCRIPT" "$PLUGIN_DIR"; then
print_error "Validation failed. Fix errors before testing."
exit 1
fi
echo
else
print_warn "validate-plugin.sh not found, skipping validation"
echo
fi
fi
# Step 2: Create temporary marketplace
print_step "Creating test marketplace"
TEST_MARKETPLACE_DIR=$(mktemp -d -t claude-test-marketplace.XXXXXX)
print_info "Test marketplace: $TEST_MARKETPLACE_DIR"
# Create marketplace structure
mkdir -p "$TEST_MARKETPLACE_DIR/.claude-plugin"
# Create marketplace.json
cat > "$TEST_MARKETPLACE_DIR/.claude-plugin/marketplace.json" << EOF
{
"name": "test-marketplace",
"version": "1.0.0",
"description": "Temporary test marketplace for plugin development",
"plugins": [
{
"name": "$PLUGIN_NAME",
"source": {
"type": "local",
"path": "$PLUGIN_DIR"
}
}
]
}
EOF
print_info "Created marketplace configuration"
echo
# Step 3: Provide test instructions
print_step "Test Instructions"
echo
echo -e "${CYAN}To test your plugin, run these commands in Claude Code:${NC}"
echo
echo -e "${YELLOW}1. Add the test marketplace:${NC}"
echo " /plugin marketplace add $TEST_MARKETPLACE_DIR"
echo
echo -e "${YELLOW}2. List available plugins:${NC}"
echo " /plugin"
echo
echo -e "${YELLOW}3. Install your plugin:${NC}"
echo " /plugin install $PLUGIN_NAME@test-marketplace"
echo
echo -e "${YELLOW}4. Test your plugin components:${NC}"
# Check what components exist and provide specific test instructions
HAS_SKILLS=false
HAS_COMMANDS=false
HAS_AGENTS=false
HAS_HOOKS=false
HAS_MCP=false
if [[ -d "$PLUGIN_DIR/skills" ]] && [[ -n "$(find "$PLUGIN_DIR/skills" -name "SKILL.md" 2>/dev/null)" ]]; then
HAS_SKILLS=true
fi
if [[ -d "$PLUGIN_DIR/commands" ]] && [[ -n "$(find "$PLUGIN_DIR/commands" -name "*.md" 2>/dev/null)" ]]; then
HAS_COMMANDS=true
fi
if [[ -d "$PLUGIN_DIR/agents" ]] && [[ -n "$(find "$PLUGIN_DIR/agents" -name "*.md" 2>/dev/null)" ]]; then
HAS_AGENTS=true
fi
if [[ -d "$PLUGIN_DIR/hooks" ]]; then
HAS_HOOKS=true
fi
if [[ -d "$PLUGIN_DIR/servers" ]]; then
HAS_MCP=true
fi
if [[ "$HAS_SKILLS" == "true" ]]; then
echo " • Skills are auto-activated - mention relevant keywords in prompts"
fi
if [[ "$HAS_COMMANDS" == "true" ]]; then
echo " • Test commands by typing slash commands (e.g., /command-name)"
echo " • List commands: /help"
fi
if [[ "$HAS_AGENTS" == "true" ]]; then
echo " • Agents are auto-invoked based on their configuration"
fi
if [[ "$HAS_HOOKS" == "true" ]]; then
echo " • Hooks run automatically on configured events"
echo " • Check settings to verify hooks are registered"
fi
if [[ "$HAS_MCP" == "true" ]]; then
echo " • MCP tools should be available automatically"
echo " • Check Claude Code logs if tools don't appear"
fi
if [[ "$HAS_SKILLS" == "false" && "$HAS_COMMANDS" == "false" && "$HAS_AGENTS" == "false" ]]; then
echo " • Plugin components not detected"
echo " • Ensure your plugin has skills/, commands/, or agents/"
fi
echo
echo -e "${YELLOW}5. Verify installation:${NC}"
echo " /plugin info $PLUGIN_NAME"
echo
echo -e "${YELLOW}6. When done testing, uninstall:${NC}"
echo " /plugin uninstall $PLUGIN_NAME"
echo " /plugin marketplace remove test-marketplace"
echo
# Step 4: Additional tips
print_step "Testing Tips"
echo
echo -e "${CYAN}Common Issues:${NC}"
echo " • If plugin doesn't appear: Check marketplace.json syntax"
echo " • If skills don't activate: Verify SKILL.md frontmatter"
echo " • If commands fail: Check command syntax and arguments"
echo " • If hooks don't fire: Ensure scripts are executable (chmod +x)"
echo " • If MCP tools missing: Check server configuration in plugin.json"
echo
echo -e "${CYAN}Debugging:${NC}"
echo " • Check Claude Code logs for errors"
echo " • Use /plugin info to see plugin details"
echo " • Verify file paths in plugin.json are correct"
echo " • Test components individually before combining"
echo
echo -e "${CYAN}Before Distribution:${NC}"
echo " • Test all plugin components thoroughly"
echo " • Update README.md with clear instructions"
echo " • Run validation: ./scripts/validate-plugin.sh $PLUGIN_DIR"
echo " • Update version in plugin.json"
echo " • Update CHANGELOG.md with changes"
echo
# Step 5: Wait or exit
if [[ "$CLEANUP_ON_EXIT" == "false" ]]; then
print_info "Test marketplace will be preserved at:"
echo " $TEST_MARKETPLACE_DIR"
echo
print_info "Remember to clean up manually when done:"
echo " rm -rf $TEST_MARKETPLACE_DIR"
echo
elif [[ "$NON_INTERACTIVE" == "true" ]]; then
print_info "Non-interactive mode: skipping wait"
print_info "Test marketplace will be cleaned up automatically"
else
print_info "Test marketplace will be cleaned up automatically"
print_warn "Press Ctrl+C to cancel and keep the marketplace"
echo
echo -e "${CYAN}Press Enter when done testing...${NC}"
read -r
fi
echo
print_info "Testing session complete!"
@@ -0,0 +1,546 @@
#!/usr/bin/env bash
# validate-plugin.sh - Comprehensive Claude Code plugin validation
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Counters
ERRORS=0
WARNINGS=0
CHECKS=0
# Options
STRICT_MODE=false
QUIET_MODE=false
FIX_MODE=false
# Print functions
print_error() {
((ERRORS++))
echo -e "${RED}✗ ERROR:${NC} $1"
}
print_warning() {
((WARNINGS++))
echo -e "${YELLOW}⚠ WARNING:${NC} $1"
}
print_info() {
[[ "$QUIET_MODE" == "false" ]] && echo -e "${BLUE} INFO:${NC} $1"
}
print_success() {
[[ "$QUIET_MODE" == "false" ]] && echo -e "${GREEN}✓ PASS:${NC} $1"
}
print_check() {
((CHECKS++))
[[ "$QUIET_MODE" == "false" ]] && echo -e "${CYAN}[CHECK $CHECKS]${NC} $1"
}
# Usage
usage() {
cat << EOF
Usage: $0 [options] <plugin-directory>
Comprehensive validation for Claude Code plugins.
Arguments:
plugin-directory Path to plugin root directory
Options:
-s, --strict Treat warnings as errors
-q, --quiet Only show errors and warnings
-f, --fix Auto-fix issues where possible
-h, --help Show this help
Examples:
# Validate current plugin
$0 .
# Validate specific plugin
$0 /path/to/my-plugin
# Strict validation
$0 --strict .
# Auto-fix common issues
$0 --fix .
Exit Codes:
0 - No errors
1 - Validation errors found
2 - Invalid arguments or plugin not found
EOF
exit 2
}
# Parse arguments
PLUGIN_DIR=""
while [[ $# -gt 0 ]]; do
case $1 in
-s|--strict)
STRICT_MODE=true
shift
;;
-q|--quiet)
QUIET_MODE=true
shift
;;
-f|--fix)
FIX_MODE=true
shift
;;
-h|--help)
usage
;;
-*)
echo -e "${RED}Error: Unknown option $1${NC}"
usage
;;
*)
PLUGIN_DIR="$1"
shift
;;
esac
done
# Validate arguments
if [[ -z "$PLUGIN_DIR" ]]; then
echo -e "${RED}Error: Plugin directory required${NC}"
usage
fi
if [[ ! -d "$PLUGIN_DIR" ]]; then
echo -e "${RED}Error: Directory not found: $PLUGIN_DIR${NC}"
exit 2
fi
# Convert to absolute path
PLUGIN_DIR=$(cd "$PLUGIN_DIR" && pwd)
# Print header
if [[ "$QUIET_MODE" == "false" ]]; then
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Claude Code Plugin Validation ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}"
echo
echo -e "${CYAN}Plugin Directory:${NC} $PLUGIN_DIR"
echo
fi
# Check 1: plugin.json exists
print_check "Checking for plugin.json"
PLUGIN_JSON="$PLUGIN_DIR/.claude-plugin/plugin.json"
if [[ ! -f "$PLUGIN_JSON" ]]; then
print_error "plugin.json not found at .claude-plugin/plugin.json"
exit 1
else
print_success "plugin.json exists"
fi
# Check 2: plugin.json is valid JSON
print_check "Validating plugin.json syntax"
if ! jq empty "$PLUGIN_JSON" 2>/dev/null; then
print_error "plugin.json contains invalid JSON"
exit 1
else
print_success "plugin.json is valid JSON"
fi
# Check 3: Required fields in plugin.json
print_check "Validating plugin.json required fields"
PLUGIN_NAME=$(jq -r '.name // empty' "$PLUGIN_JSON")
PLUGIN_VERSION=$(jq -r '.version // empty' "$PLUGIN_JSON")
PLUGIN_DESC=$(jq -r '.description // empty' "$PLUGIN_JSON")
if [[ -z "$PLUGIN_NAME" ]]; then
print_error "plugin.json missing required field: name"
else
print_success "Plugin name: $PLUGIN_NAME"
# Validate name format
if [[ ! "$PLUGIN_NAME" =~ ^[a-z][a-z0-9-]*$ ]]; then
print_error "Plugin name must be kebab-case: $PLUGIN_NAME"
fi
fi
if [[ -z "$PLUGIN_VERSION" ]]; then
print_error "plugin.json missing required field: version"
else
print_success "Plugin version: $PLUGIN_VERSION"
# Validate semantic versioning
if [[ ! "$PLUGIN_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
print_warning "Version should follow semantic versioning (e.g., 1.0.0)"
fi
fi
if [[ -z "$PLUGIN_DESC" ]]; then
print_warning "plugin.json missing recommended field: description"
else
print_success "Plugin description present"
# Check description length
DESC_LEN=${#PLUGIN_DESC}
if [[ $DESC_LEN -lt 20 ]]; then
print_warning "Description is very short ($DESC_LEN chars)"
elif [[ $DESC_LEN -gt 200 ]]; then
print_warning "Description is very long ($DESC_LEN chars), consider shortening"
fi
fi
# Check 4: Author info
print_check "Validating author information"
AUTHOR_NAME=$(jq -r '.author.name // empty' "$PLUGIN_JSON")
AUTHOR_EMAIL=$(jq -r '.author.email // empty' "$PLUGIN_JSON")
if [[ -z "$AUTHOR_NAME" ]]; then
print_warning "plugin.json missing recommended field: author.name"
else
print_success "Author: $AUTHOR_NAME"
fi
if [[ -z "$AUTHOR_EMAIL" ]]; then
print_warning "plugin.json missing recommended field: author.email"
elif [[ ! "$AUTHOR_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
print_warning "Author email appears invalid: $AUTHOR_EMAIL"
fi
# Check 5: License
print_check "Checking license"
PLUGIN_LICENSE=$(jq -r '.license // empty' "$PLUGIN_JSON")
if [[ -z "$PLUGIN_LICENSE" ]]; then
print_warning "plugin.json missing recommended field: license"
else
print_success "License: $PLUGIN_LICENSE"
# Check for LICENSE file
if [[ ! -f "$PLUGIN_DIR/LICENSE" ]] && [[ ! -f "$PLUGIN_DIR/LICENSE.md" ]]; then
print_warning "No LICENSE file found in plugin root"
fi
fi
# Check 6: README.md
print_check "Checking README.md"
if [[ ! -f "$PLUGIN_DIR/README.md" ]]; then
print_warning "No README.md found in plugin root"
else
print_success "README.md exists"
# Check README length
README_LINES=$(wc -l < "$PLUGIN_DIR/README.md")
if [[ $README_LINES -lt 10 ]]; then
print_warning "README.md is very short ($README_LINES lines)"
fi
fi
# Check 7: Validate skills
print_check "Validating skills"
SKILLS_DIR="$PLUGIN_DIR/skills"
if [[ -d "$SKILLS_DIR" ]]; then
SKILL_COUNT=$(find "$SKILLS_DIR" -name "SKILL.md" | wc -l | xargs)
if [[ $SKILL_COUNT -eq 0 ]]; then
print_warning "skills/ directory exists but contains no SKILL.md files"
else
print_success "Found $SKILL_COUNT skill(s)"
# Validate each skill
while IFS= read -r skill_file; do
SKILL_NAME=$(dirname "$skill_file" | xargs basename)
print_info "Validating skill: $SKILL_NAME"
# Check for frontmatter
if ! head -n 1 "$skill_file" | grep -q '^---$'; then
print_warning "Skill $SKILL_NAME missing frontmatter"
else
# Validate required frontmatter fields
SKILL_FRONTMATTER=$(awk '/^---$/{flag=!flag; next} flag' "$skill_file" | head -n 20)
if ! echo "$SKILL_FRONTMATTER" | grep -q '^name:'; then
print_error "Skill $SKILL_NAME missing 'name' in frontmatter"
fi
if ! echo "$SKILL_FRONTMATTER" | grep -q '^description:'; then
print_error "Skill $SKILL_NAME missing 'description' in frontmatter"
fi
if ! echo "$SKILL_FRONTMATTER" | grep -q '^version:'; then
print_warning "Skill $SKILL_NAME missing 'version' in frontmatter"
fi
fi
# Check file size (skills should have substantial content)
SKILL_SIZE=$(wc -c < "$skill_file")
if [[ $SKILL_SIZE -lt 500 ]]; then
print_warning "Skill $SKILL_NAME is very small ($SKILL_SIZE bytes)"
fi
done < <(find "$SKILLS_DIR" -name "SKILL.md")
fi
else
print_info "No skills/ directory found"
fi
# Check 8: Validate commands
print_check "Validating slash commands"
COMMANDS_DIR="$PLUGIN_DIR/commands"
if [[ -d "$COMMANDS_DIR" ]]; then
COMMAND_COUNT=$(find "$COMMANDS_DIR" -name "*.md" | wc -l | xargs)
if [[ $COMMAND_COUNT -eq 0 ]]; then
print_warning "commands/ directory exists but contains no .md files"
else
print_success "Found $COMMAND_COUNT command(s)"
# Validate each command
while IFS= read -r cmd_file; do
CMD_NAME=$(basename "$cmd_file" .md)
print_info "Validating command: $CMD_NAME"
# Check filename format
if [[ ! "$CMD_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
print_warning "Command name should be kebab-case: $CMD_NAME"
fi
# Check if file is empty
if [[ ! -s "$cmd_file" ]]; then
print_error "Command $CMD_NAME is empty"
continue
fi
# Check for frontmatter
if head -n 1 "$cmd_file" | grep -q '^---$'; then
# Validate frontmatter syntax
FRONTMATTER=$(awk '/^---$/{flag=!flag; next} flag' "$cmd_file" | head -n 20)
# Check for description
if echo "$FRONTMATTER" | grep -q '^description:'; then
DESC=$(echo "$FRONTMATTER" | grep '^description:' | sed 's/^description: *//')
if [[ -z "$DESC" ]]; then
print_warning "Command $CMD_NAME has empty description"
fi
fi
# Check for tabs in frontmatter (YAML doesn't allow tabs)
if echo "$FRONTMATTER" | grep -q $'\t'; then
print_error "Command $CMD_NAME frontmatter contains tabs (use spaces)"
fi
fi
done < <(find "$COMMANDS_DIR" -name "*.md")
fi
else
print_info "No commands/ directory found"
fi
# Check 9: Validate agents
print_check "Validating custom agents"
AGENTS_DIR="$PLUGIN_DIR/agents"
if [[ -d "$AGENTS_DIR" ]]; then
AGENT_COUNT=$(find "$AGENTS_DIR" -name "*.md" | wc -l | xargs)
if [[ $AGENT_COUNT -eq 0 ]]; then
print_warning "agents/ directory exists but contains no .md files"
else
print_success "Found $AGENT_COUNT agent(s)"
# Check if agents are referenced in plugin.json
AGENTS_IN_JSON=$(jq -r '.agents // [] | length' "$PLUGIN_JSON")
if [[ $AGENTS_IN_JSON -eq 0 ]]; then
print_warning "Agents found but not referenced in plugin.json"
fi
# Validate each agent
while IFS= read -r agent_file; do
AGENT_NAME=$(basename "$agent_file" .md)
print_info "Validating agent: $AGENT_NAME"
# Check for frontmatter
if ! head -n 1 "$agent_file" | grep -q '^---$'; then
print_warning "Agent $AGENT_NAME missing frontmatter"
else
AGENT_FRONTMATTER=$(awk '/^---$/{flag=!flag; next} flag' "$agent_file" | head -n 20)
if ! echo "$AGENT_FRONTMATTER" | grep -q '^name:'; then
print_error "Agent $AGENT_NAME missing 'name' in frontmatter"
fi
if ! echo "$AGENT_FRONTMATTER" | grep -q '^description:'; then
print_error "Agent $AGENT_NAME missing 'description' in frontmatter"
fi
fi
done < <(find "$AGENTS_DIR" -name "*.md")
fi
else
print_info "No agents/ directory found"
fi
# Check 10: Validate hooks
print_check "Validating event hooks"
HOOKS_DIR="$PLUGIN_DIR/hooks"
if [[ -d "$HOOKS_DIR" ]]; then
HOOK_COUNT=$(find "$HOOKS_DIR" -type f | wc -l | xargs)
if [[ $HOOK_COUNT -eq 0 ]]; then
print_warning "hooks/ directory exists but contains no files"
else
print_success "Found $HOOK_COUNT hook file(s)"
# Check if hooks are referenced in plugin.json
HOOKS_IN_JSON=$(jq -r '.hooks // {} | length' "$PLUGIN_JSON")
if [[ $HOOKS_IN_JSON -eq 0 ]]; then
print_warning "Hook files found but not configured in plugin.json"
fi
# Validate each hook script
while IFS= read -r hook_file; do
HOOK_NAME=$(basename "$hook_file")
print_info "Validating hook: $HOOK_NAME"
# Check if file is executable
if [[ ! -x "$hook_file" ]]; then
if [[ "$FIX_MODE" == "true" ]]; then
chmod +x "$hook_file"
print_info "Fixed: Made $HOOK_NAME executable"
else
print_warning "Hook $HOOK_NAME is not executable (use chmod +x)"
fi
fi
# Check for shebang
if ! head -n 1 "$hook_file" | grep -q '^#!'; then
print_warning "Hook $HOOK_NAME missing shebang line"
fi
done < <(find "$HOOKS_DIR" -type f)
fi
else
print_info "No hooks/ directory found"
fi
# Check 11: Validate MCP servers
print_check "Validating MCP servers"
SERVERS_DIR="$PLUGIN_DIR/servers"
if [[ -d "$SERVERS_DIR" ]]; then
SERVER_COUNT=$(find "$SERVERS_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l | xargs)
if [[ $SERVER_COUNT -eq 0 ]]; then
print_warning "servers/ directory exists but contains no server directories"
else
print_success "Found $SERVER_COUNT MCP server(s)"
# Check if servers are referenced in plugin.json
SERVERS_IN_JSON=$(jq -r '.mcpServers // {} | length' "$PLUGIN_JSON")
if [[ $SERVERS_IN_JSON -eq 0 ]]; then
print_warning "MCP servers found but not configured in plugin.json"
fi
# Validate each server
while IFS= read -r server_dir; do
SERVER_NAME=$(basename "$server_dir")
print_info "Validating MCP server: $SERVER_NAME"
# Check for server implementation
if [[ -f "$server_dir/server.py" ]]; then
print_success "Found Python server implementation"
# Check for pyproject.toml
if [[ ! -f "$server_dir/pyproject.toml" ]]; then
print_warning "Server $SERVER_NAME missing pyproject.toml"
fi
elif [[ -f "$server_dir/index.js" ]] || [[ -f "$server_dir/index.ts" ]]; then
print_success "Found Node.js server implementation"
# Check for package.json
if [[ ! -f "$server_dir/package.json" ]]; then
print_warning "Server $SERVER_NAME missing package.json"
fi
else
print_warning "Server $SERVER_NAME missing server implementation file"
fi
done < <(find "$SERVERS_DIR" -mindepth 1 -maxdepth 1 -type d)
fi
else
print_info "No servers/ directory found"
fi
# Check 12: Check for common files
print_check "Checking for common files"
if [[ ! -f "$PLUGIN_DIR/.gitignore" ]]; then
print_warning "No .gitignore found"
else
print_success ".gitignore exists"
fi
if [[ ! -f "$PLUGIN_DIR/CHANGELOG.md" ]]; then
print_warning "No CHANGELOG.md found (recommended for versioning)"
else
print_success "CHANGELOG.md exists"
fi
# Check 13: Git repository
print_check "Checking git repository"
if [[ ! -d "$PLUGIN_DIR/.git" ]]; then
print_info "Not a git repository"
else
print_success "Git repository initialized"
# Check for uncommitted changes
if ! git -C "$PLUGIN_DIR" diff-index --quiet HEAD -- 2>/dev/null; then
print_info "Repository has uncommitted changes"
fi
fi
# Summary
echo
echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Validation Summary ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}"
echo
echo -e "${CYAN}Checks Performed:${NC} $CHECKS"
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}Errors Found:${NC} $ERRORS"
fi
if [[ $WARNINGS -gt 0 ]]; then
echo -e "${YELLOW}Warnings Found:${NC} $WARNINGS"
fi
echo
# Convert warnings to errors in strict mode
if [[ "$STRICT_MODE" == "true" && $WARNINGS -gt 0 ]]; then
ERRORS=$((ERRORS + WARNINGS))
WARNINGS=0
echo -e "${YELLOW}(Strict mode: warnings treated as errors)${NC}"
echo
fi
# Exit with appropriate code
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
echo -e "${GREEN}✓ Validation passed! Plugin is ready to use.${NC}"
exit 0
elif [[ $ERRORS -eq 0 ]]; then
echo -e "${YELLOW}⚠ Validation passed with warnings.${NC}"
echo -e "${YELLOW} Consider addressing warnings before distribution.${NC}"
exit 0
else
echo -e "${RED}✗ Validation failed!${NC}"
echo -e "${RED} Please fix errors before using this plugin.${NC}"
exit 1
fi
@@ -0,0 +1,493 @@
#!/usr/bin/env bun
/**
* validate-skill-frontmatter.ts
*
* Validates SKILL.md frontmatter against the Agent Skills specification.
* Designed for use as a PreToolUse hook for Write/Edit operations on SKILL.md files.
*
* Exit codes:
* 0 - Valid/skip (proceed with tool use)
* 2 - Block (critical errors)
*
* Input: JSON on stdin from Claude Code PreToolUse hook
* {
* "tool_name": "Write" | "Edit",
* "tool_input": { "file_path": string, "content"?: string, "old_string"?: string, "new_string"?: string }
* }
*/
import { readFileSync } from "fs";
import { basename, dirname } from "path";
import { parse as parseYaml } from "yaml";
// Import context detection (if available)
const RESERVED_WORDS = ["anthropic", "claude"];
const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
const MAX_LINES = 500;
const MAX_DESCRIPTION_LENGTH = 1024;
const MIN_DESCRIPTION_LENGTH = 10;
/**
* Result of skill frontmatter validation.
*/
interface ValidationResult {
/** Whether the frontmatter passes all required checks */
valid: boolean;
/** Blocking errors that must be fixed */
errors: string[];
/** Non-blocking warnings for improvement */
warnings: string[];
/** Parsed frontmatter if YAML was valid */
frontmatter: Record<string, unknown> | null;
}
/**
* Expected structure of SKILL.md frontmatter.
*/
interface SkillFrontmatter {
name?: string;
description?: string;
version?: string;
license?: string;
compatibility?: string;
metadata?: Record<string, unknown>;
"allowed-tools"?: string;
"user-invocable"?: boolean;
"disable-model-invocation"?: boolean;
context?: string;
agent?: string;
model?: string;
hooks?: Record<string, string>;
"argument-hint"?: string;
[key: string]: unknown;
}
// Base spec fields (cross-platform)
const BASE_FIELDS = new Set([
"name",
"description",
"version",
"license",
"compatibility",
"metadata",
]);
// Claude-specific extension fields
const CLAUDE_FIELDS = new Set([
"allowed-tools",
"user-invocable",
"disable-model-invocation",
"context",
"agent",
"model",
"hooks",
"argument-hint",
]);
/**
* Extracts YAML frontmatter from markdown content.
* @param content - Full markdown file content
* @returns Object with extracted YAML string and total line count
*/
function extractFrontmatter(content: string): {
yaml: string | null;
lineCount: number;
} {
const lines = content.split("\n");
const lineCount = lines.length;
if (!lines[0]?.trim().startsWith("---")) {
return { yaml: null, lineCount };
}
let endIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === "---") {
endIndex = i;
break;
}
}
if (endIndex === -1) {
return { yaml: null, lineCount };
}
const yaml = lines.slice(1, endIndex).join("\n");
return { yaml, lineCount };
}
/**
* Checks if a file path indicates Claude Code context.
* @param path - File path to check
* @returns True if path matches Claude skill locations
*/
function detectClaudeContext(path: string): boolean {
const patterns = [
/\.claude-plugin\//,
/\.claude\/skills\//,
/\/\.claude\/skills\//,
];
return patterns.some((p) => p.test(path));
}
/**
* Validates SKILL.md content against the Agent Skills specification.
* @param content - Full SKILL.md file content
* @param filePath - Path to the file (used for context detection)
* @returns Validation result with errors, warnings, and parsed frontmatter
*/
function validate(
content: string,
filePath: string
): ValidationResult {
const result: ValidationResult = {
valid: true,
errors: [],
warnings: [],
frontmatter: null,
};
const { yaml, lineCount } = extractFrontmatter(content);
// Check for YAML syntax
if (yaml === null) {
result.valid = false;
result.errors.push(
"Missing or invalid frontmatter. SKILL.md must start with --- and have closing ---"
);
return result;
}
// Check for tabs (YAML requires spaces)
if (yaml.includes("\t")) {
result.valid = false;
result.errors.push(
"YAML contains tabs. Use spaces for indentation (YAML specification requirement)"
);
}
// Parse YAML
let frontmatter: SkillFrontmatter;
try {
frontmatter = parseYaml(yaml) as SkillFrontmatter;
result.frontmatter = frontmatter;
} catch (e) {
result.valid = false;
result.errors.push(
`YAML parse error: ${e instanceof Error ? e.message : String(e)}`
);
return result;
}
if (!frontmatter || typeof frontmatter !== "object") {
result.valid = false;
result.errors.push("Frontmatter must be a YAML object");
return result;
}
// Required fields
if (!frontmatter.name) {
result.valid = false;
result.errors.push("Missing required field: name");
}
if (!frontmatter.description) {
result.valid = false;
result.errors.push("Missing required field: description");
}
// Name validation
if (frontmatter.name) {
const name = frontmatter.name;
// Pattern check
if (!NAME_PATTERN.test(name)) {
result.valid = false;
result.errors.push(
`Invalid name format: '${name}'. Must be lowercase, numbers, hyphens only. Pattern: ${NAME_PATTERN}`
);
}
// Length check
if (name.length < 2 || name.length > 64) {
result.valid = false;
result.errors.push(
`Name length must be 2-64 characters. Got: ${name.length}`
);
}
// Reserved words
for (const reserved of RESERVED_WORDS) {
if (name.toLowerCase().includes(reserved)) {
result.valid = false;
result.errors.push(
`Name cannot contain reserved word: '${reserved}'. Found in: '${name}'`
);
}
}
// Directory match (if file path provided)
if (filePath && filePath !== "--stdin") {
const parentDir = basename(dirname(filePath));
if (parentDir !== name && parentDir !== "skills") {
result.warnings.push(
`Name '${name}' does not match parent directory '${parentDir}'. Consider renaming for consistency.`
);
}
}
}
// Description validation
if (frontmatter.description) {
const desc = frontmatter.description;
if (desc.length < MIN_DESCRIPTION_LENGTH) {
result.valid = false;
result.errors.push(
`Description too short: ${desc.length} chars. Minimum: ${MIN_DESCRIPTION_LENGTH}`
);
}
if (desc.length > MAX_DESCRIPTION_LENGTH) {
result.valid = false;
result.errors.push(
`Description too long: ${desc.length} chars. Maximum: ${MAX_DESCRIPTION_LENGTH}`
);
}
// Quality warnings
const hasWhat = /\b(extracts?|process(es)?|creates?|generates?|validates?|manages?|handles?|analyzes?|reviews?|debugs?|implements?)\b/i.test(desc);
const hasWhen = /\b(use when|when working|when the user|when you need)\b/i.test(desc);
if (!hasWhat) {
result.warnings.push(
"Description should include WHAT the skill does (verbs like 'extracts', 'processes', 'creates')"
);
}
if (!hasWhen) {
result.warnings.push(
"Description should include WHEN to use it (e.g., 'Use when working with...')"
);
}
}
// Check for custom fields at top level (should be under metadata)
const allKnownFields = new Set([...BASE_FIELDS, ...CLAUDE_FIELDS]);
for (const key of Object.keys(frontmatter)) {
if (!allKnownFields.has(key)) {
result.warnings.push(
`Custom field '${key}' should be nested under 'metadata'. Top-level custom fields may cause parsing issues.`
);
}
}
// Line count warning
if (lineCount > MAX_LINES) {
result.warnings.push(
`SKILL.md has ${lineCount} lines (recommended max: ${MAX_LINES}). Consider moving details to references/.`
);
}
// Claude context recommendations
const isClaudeContext = detectClaudeContext(filePath);
if (isClaudeContext) {
if (!frontmatter["allowed-tools"]) {
result.warnings.push(
"Claude context detected. Consider adding 'allowed-tools' for tool permissions."
);
}
}
return result;
}
/**
* Formats validation result for human-readable console output.
* @param result - Validation result to format
* @param path - Original file path for display
*/
function formatOutput(result: ValidationResult, path: string): void {
const status = result.valid
? result.warnings.length > 0
? "WARNINGS"
: "PASS"
: "FAIL";
console.log(`# Skill Validation: ${basename(dirname(path))}`);
console.log(`**Status**: ${status}`);
console.log(
`**Issues**: ${result.errors.length} errors, ${result.warnings.length} warnings`
);
if (result.errors.length > 0) {
console.log("\n## Errors (must fix)");
for (const error of result.errors) {
console.log(`- ${error}`);
}
}
if (result.warnings.length > 0) {
console.log("\n## Warnings (should fix)");
for (const warning of result.warnings) {
console.log(`- ${warning}`);
}
}
if (result.valid && result.warnings.length === 0) {
console.log("\n✓ All checks passed");
}
}
/**
* Hook input from Claude Code PreToolUse
*/
interface HookInput {
tool_name: string;
tool_input: {
file_path: string;
content?: string; // Write tool
old_string?: string; // Edit tool
new_string?: string; // Edit tool
};
}
/**
* Quick check: does this string look like it might affect frontmatter?
* Used to bail out fast on Edit operations that don't touch frontmatter.
*/
function mightAffectFrontmatter(str: string): boolean {
// Contains frontmatter delimiter
if (str.includes("---")) return true;
// Contains YAML-like key: value pattern
if (/^[a-z][-a-z]*:/m.test(str)) return true;
return false;
}
/**
* Read JSON from stdin with timeout protection
*/
async function readStdin(timeoutMs = 5000): Promise<string> {
const chunks: Buffer[] = [];
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("stdin timeout")), timeoutMs);
});
const read = (async () => {
for await (const chunk of Bun.stdin.stream()) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf-8");
})();
return Promise.race([read, timeout]);
}
async function main() {
const args = process.argv.slice(2);
// CLI mode for manual testing
if (args.length > 0 && !args[0].startsWith("{")) {
if (args[0] === "--help" || args[0] === "-h") {
console.log(`Usage: validate-skill-frontmatter.ts [file]
PreToolUse hook for SKILL.md frontmatter validation.
Hook mode (default): Reads JSON from stdin
CLI mode: Pass file path as argument for manual testing
Exit codes:
0 - Valid/skip (proceed)
2 - Block (errors)
`);
process.exit(0);
}
// Manual file validation
const filePath = args[0];
let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch (e) {
console.error(`Error reading file: ${filePath}`);
process.exit(2);
}
const result = validate(content, filePath);
formatOutput(result, filePath);
process.exit(result.valid ? 0 : 2);
}
// Hook mode: read JSON from stdin
let input: HookInput;
try {
const raw = await readStdin();
input = JSON.parse(raw);
} catch (e) {
// Can't parse input → don't block, exit cleanly
process.exit(0);
}
const { tool_name, tool_input } = input;
const filePath = tool_input?.file_path ?? "";
// Fast bailout: Edit that doesn't touch frontmatter
if (tool_name === "Edit") {
const oldStr = tool_input.old_string ?? "";
const newStr = tool_input.new_string ?? "";
if (!mightAffectFrontmatter(oldStr) && !mightAffectFrontmatter(newStr)) {
// Edit doesn't touch frontmatter area → skip validation
process.exit(0);
}
// For Edit, we need current file + apply changes to validate
// Read current file, apply edit, validate result
let currentContent: string;
try {
currentContent = readFileSync(filePath, "utf-8");
} catch {
// File doesn't exist yet or can't read → skip
process.exit(0);
}
// Apply the edit
if (!currentContent.includes(oldStr)) {
// old_string not found → Claude will error anyway, don't block
process.exit(0);
}
const newContent = currentContent.replace(oldStr, newStr);
const result = validate(newContent, filePath);
if (!result.valid) {
formatOutput(result, filePath);
process.exit(2);
}
process.exit(0);
}
// Write tool: validate the new content directly
if (tool_name === "Write") {
const content = tool_input.content ?? "";
if (!content.trim()) {
// Empty content → don't block
process.exit(0);
}
const result = validate(content, filePath);
if (!result.valid) {
formatOutput(result, filePath);
process.exit(2);
}
process.exit(0);
}
// Unknown tool → don't block
process.exit(0);
}
main();