📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -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
@@ -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
@@ -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();
|
||||
Reference in New Issue
Block a user