diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index f525fd5..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "plaited-marketplace", - "metadata": { - "description": "Official Claude Code plugins from Plaited Labs for development workflows, agent testing, and the Plaited framework" - }, - "owner": { - "name": "Plaited Labs" - }, - "plugins": [ - { - "name": "development-skills", - "description": "Development skills for Claude Code - TypeScript LSP, code documentation, and validation tools", - "source": { - "source": "github", - "repo": "plaited/development-skills" - }, - "category": "development", - "keywords": ["typescript", "javascript", "lsp", "language-server", "symbols", "refactoring", "documentation", "validation"] - }, - { - "name": "acp-harness", - "description": "ACP client and evaluation harness for agent testing", - "source": { - "source": "github", - "repo": "plaited/acp-harness" - }, - "category": "development", - "keywords": ["acp", "evaluation", "testing", "harness", "agents", "benchmarking"] - }, - { - "name": "plaited", - "description": "Plaited framework development tools - behavioral programming, UI patterns, and web components", - "source": { - "source": "github", - "repo": "plaited/plaited" - }, - "category": "development", - "keywords": ["plaited", "behavioral-programming", "ui-patterns", "web-patterns", "web-components", "loom", "standards"] - } - ] -} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6c619be --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "agent-skills-spec": { + "type": "http", + "url": "https://agentskills.io/mcp" + }, + "bun-docs" : { + "type": "http", + "url": "https://bun.com/docs/mcp" + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c000314 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Plaited Labs + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md index 976c597..60474c9 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,31 @@ -# Plaited Marketplace +# Plaited Skills Installer -Aggregator for Plaited's Claude Code plugins. +[![CI](https://github.com/plaited/skills-installer/actions/workflows/ci.yml/badge.svg)](https://github.com/plaited/skills-installer/actions/workflows/ci.yml) +[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC) + +Install Plaited skills for AI coding agents supporting the agent-skills-spec. ## Installation -### Claude Code +For agents supporting the agent-skills-spec (Gemini CLI, GitHub Copilot, Cursor, OpenCode, Amp, Goose, Factory, Codex, Windsurf): ```bash -claude plugins add github:plaited/marketplace -``` - -### Other AI Coding Agents +# Install all projects +curl -fsSL https://raw.githubusercontent.com/plaited/skills-installer/main/install.sh | bash -s -- --agent -For agents supporting the AgentSkills spec (Gemini CLI, GitHub Copilot, Cursor, OpenCode, Amp, Goose, Factory): - -```bash -# Install a specific plugin -curl -fsSL https://raw.githubusercontent.com/plaited/marketplace/main/install.sh | bash -s -- --agent --plugin development-skills +# Install a specific project +curl -fsSL https://raw.githubusercontent.com/plaited/skills-installer/main/install.sh | bash -s -- --agent --project development-skills ``` **Or clone and run locally:** ```bash -git clone https://github.com/plaited/marketplace.git -cd marketplace +git clone https://github.com/plaited/skills-installer.git +cd skills-installer ./install.sh # Interactive mode ./install.sh --agent gemini # Install all for Gemini CLI -./install.sh --agent cursor --plugin acp-harness # Specific plugin -./install.sh --list # List available plugins +./install.sh --agent cursor --project acp-harness # Specific project +./install.sh --list # List available projects ./install.sh --update # Update existing ./install.sh --uninstall # Remove all ``` @@ -36,23 +34,25 @@ cd marketplace | Agent | Skills | Commands | |-------|--------|----------| -| gemini | `.gemini/skills/` | - | +| gemini | `.gemini/skills/` | `.gemini/commands/` (→TOML) | | copilot | `.github/skills/` | - | | cursor | `.cursor/skills/` | `.cursor/commands/` | | opencode | `.opencode/skill/` | `.opencode/command/` | | amp | `.amp/skills/` | `.amp/commands/` | | goose | `.goose/skills/` | - | | factory | `.factory/skills/` | `.factory/commands/` | +| codex | `.codex/skills/` | `~/.codex/prompts/` (→prompt) | +| windsurf | `.windsurf/skills/` | `.windsurf/workflows/` | -## Available Plugins +## Available Projects -| Plugin | Description | -|--------|-------------| -| **development-skills** | Development skills for Claude Code - TypeScript LSP, code documentation, and validation tools | -| **acp-harness** | ACP client and evaluation harness for agent testing | -| **plaited** | Plaited framework development tools - behavioral programming, UI patterns, and web components | +| Project | Source | +|---------|--------| +| **development-skills** | [plaited/development-skills](https://github.com/plaited/development-skills) | +| **acp-harness** | [plaited/acp-harness](https://github.com/plaited/acp-harness) | +| **plaited** | [plaited/plaited](https://github.com/plaited/plaited) | -## Plugin Details +## Project Details ### development-skills diff --git a/install.sh b/install.sh index 334a497..d455fad 100755 --- a/install.sh +++ b/install.sh @@ -1,15 +1,12 @@ #!/bin/bash -# Install Plaited plugins for AI coding agents supporting agent-skills-spec -# Supports: Gemini CLI, GitHub Copilot, Cursor, OpenCode, Amp, Goose, Factory -# -# NOTE: Claude Code users should use the plugin marketplace instead: -# claude plugins add github:plaited/marketplace +# Install Plaited skills for AI coding agents supporting agent-skills-spec +# Supports: Gemini CLI, GitHub Copilot, Cursor, OpenCode, Amp, Goose, Factory, Codex, Windsurf # # Usage: # ./install.sh # Interactive: asks which agent # ./install.sh --agent gemini # Direct: install for Gemini CLI -# ./install.sh --plugin typescript-lsp # Install specific plugin only -# ./install.sh --list # List available plugins +# ./install.sh --project development-skills # Install specific project only +# ./install.sh --list # List available projects # ./install.sh --update # Update existing installation # ./install.sh --uninstall # Remove installation @@ -20,9 +17,53 @@ set -e # ============================================================================ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MARKETPLACE_JSON="$SCRIPT_DIR/.claude-plugin/marketplace.json" +PROJECTS_JSON="$SCRIPT_DIR/projects.json" BRANCH="main" TEMP_DIR="" +TEMP_PROJECTS_JSON="" # Track if projects.json is a temp file that needs cleanup + +# Security: Maximum file size for reading (100KB) to prevent resource exhaustion +MAX_FILE_SIZE=102400 + +# Windsurf workflow character limit (12000 with 500 char buffer for truncation message) +WINDSURF_CHAR_LIMIT=11500 + +# Safely read file contents with size limit check +# Get file size using stat (more efficient than wc -c as it doesn't read the file) +# Outputs file size in bytes to stdout +get_file_size() { + local file="$1" + local size + + # macOS uses -f%z, Linux uses -c%s + if size=$(stat -f%z "$file" 2>/dev/null); then + printf '%s' "$size" + elif size=$(stat -c%s "$file" 2>/dev/null); then + printf '%s' "$size" + else + # Fallback to wc -c if stat doesn't work + wc -c < "$file" | tr -d ' ' + fi +} + +safe_read_file() { + local file="$1" + local max_size="${2:-$MAX_FILE_SIZE}" + + if [ ! -f "$file" ]; then + return 1 + fi + + local file_size + file_size=$(get_file_size "$file") + + if [ "$file_size" -gt "$max_size" ]; then + print_error "File exceeds size limit ($max_size bytes): $file" + return 1 + fi + + cat "$file" +} # ============================================================================ # Agent Directory Mappings (functions for bash 3.x compatibility) @@ -37,6 +78,8 @@ get_skills_dir() { amp) echo ".amp/skills" ;; goose) echo ".goose/skills" ;; factory) echo ".factory/skills" ;; + codex) echo ".codex/skills" ;; + windsurf) echo ".windsurf/skills" ;; *) echo "" ;; esac } @@ -50,14 +93,36 @@ get_commands_dir() { amp) echo ".amp/commands" ;; goose) echo ".goose/commands" ;; factory) echo ".factory/commands" ;; + codex) echo "" ;; # Codex uses ~/.codex/prompts/ (user-scoped) + windsurf) echo ".windsurf/workflows" ;; # Windsurf uses workflows, not commands *) echo "" ;; esac } +# Get the prompts directory for agents that use user-scoped prompts +# Note: Codex uses a global ~/.codex/prompts directory (user-scoped, not project-local). +# This means all Codex projects share the same prompts. This is intentional as Codex +# custom prompts are designed to be user-level, not project-level. See Codex docs for details. +get_prompts_dir() { + case "$1" in + codex) echo "$HOME/.codex/prompts" ;; + *) echo "" ;; + esac +} + supports_commands() { - # Agents that support slash commands + # Agents that support slash commands (native or converted) case "$1" in - cursor|opencode|amp|factory) return 0 ;; + gemini|cursor|opencode|amp|factory) return 0 ;; + codex|windsurf) return 0 ;; # Supported via conversion + *) return 1 ;; + esac +} + +# Check if agent needs command format conversion +needs_command_conversion() { + case "$1" in + gemini|codex|windsurf) return 0 ;; *) return 1 ;; esac } @@ -69,7 +134,7 @@ supports_commands() { print_header() { echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Plaited Marketplace Installer" + echo " Plaited Skills Installer" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" } @@ -90,89 +155,324 @@ cleanup() { if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then rm -rf "$TEMP_DIR" fi + # Clean up temp projects.json if it was fetched remotely + if [ -n "$TEMP_PROJECTS_JSON" ] && [ -f "$TEMP_PROJECTS_JSON" ]; then + rm -f "$TEMP_PROJECTS_JSON" + fi } trap cleanup EXIT # ============================================================================ -# Marketplace JSON Parsing +# Projects JSON Parsing # ============================================================================ -# Parse marketplace.json without jq (for broader compatibility) -# Only extracts plugin names (entries with both "name" and "source" fields) -get_plugin_names() { - # Use awk to find plugin entries (those with source field nearby) +# Parse projects.json without jq (for broader compatibility) +get_project_names() { awk ' - /"plugins"[[:space:]]*:/ { in_plugins=1 } - in_plugins && /"name"[[:space:]]*:/ { + /"projects"[[:space:]]*:/ { in_projects=1 } + in_projects && /"name"[[:space:]]*:/ { gsub(/.*"name"[[:space:]]*:[[:space:]]*"/, "") gsub(/".*/, "") - name=$0 - } - in_plugins && /"source"[[:space:]]*:/ && name { - print name - name="" + print } - ' "$MARKETPLACE_JSON" + ' "$PROJECTS_JSON" } -get_plugin_source() { - local plugin_name="$1" - # Find the plugin block and extract its source.repo value - # Source is now an object: { "source": "github", "repo": "org/repo" } - awk -v name="$plugin_name" ' - /"name"[[:space:]]*:[[:space:]]*"'"$plugin_name"'"/ { found=1 } +get_project_repo() { + local project_name="$1" + awk -v name="$project_name" ' + /"name"[[:space:]]*:[[:space:]]*"'"$project_name"'"/ { found=1 } found && /"repo"[[:space:]]*:/ { gsub(/.*"repo"[[:space:]]*:[[:space:]]*"/, "") gsub(/".*/, "") print exit } - ' "$MARKETPLACE_JSON" -} - -get_plugin_description() { - local plugin_name="$1" - awk -v name="$plugin_name" ' - /"name"[[:space:]]*:[[:space:]]*"'"$plugin_name"'"/ { found=1 } - found && /"description"[[:space:]]*:/ { - gsub(/.*"description"[[:space:]]*:[[:space:]]*"/, "") - gsub(/".*/, "") - print - exit - } - ' "$MARKETPLACE_JSON" + ' "$PROJECTS_JSON" } # Parse source repo like "plaited/development-skills" # Returns: repo_url sparse_path (always .claude) parse_source() { local repo="$1" + + # Validate against path traversal attacks + if [[ "$repo" =~ \.\. ]]; then + print_error "Invalid repository path (path traversal detected): $repo" + return 1 + fi + + # Validate repo format (owner/repo) + if ! [[ "$repo" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then + print_error "Invalid repository format: $repo (expected: owner/repo)" + return 1 + fi + echo "https://github.com/$repo.git" ".claude" } # ============================================================================ -# Agent Detection +# Format Conversion +# ============================================================================ +# +# Conversion Algorithm Overview: +# ----------------------------- +# Different AI agents expect commands/prompts in different formats. +# These functions convert from the standard agent-skills-spec markdown format +# to each agent's native format. +# +# Source format (agent-skills-spec): +# - Markdown file with optional YAML frontmatter (---) +# - Frontmatter may contain: name, description, allowed-tools +# - Body contains the prompt/command instructions +# - May use $ARGUMENTS placeholder for user input +# +# Target formats: +# 1. Gemini TOML: description + prompt fields in TOML syntax +# 2. Codex prompt: Markdown with description/argument-hint frontmatter +# 3. Windsurf workflow: Structured markdown with title, description, steps +# +# Security considerations: +# - All file reads go through safe_read_file() with size limits +# - No shell expansion of file content (uses printf, not echo) +# - AWK patterns are static, not user-controlled # ============================================================================ -detect_agent() { - if [ -d ".gemini" ]; then - echo "gemini" - elif [ -d ".github" ]; then - echo "copilot" - elif [ -d ".cursor" ]; then - echo "cursor" - elif [ -d ".opencode" ]; then - echo "opencode" - elif [ -d ".amp" ]; then - echo "amp" - elif [ -d ".goose" ]; then - echo "goose" - elif [ -d ".factory" ]; then - echo "factory" +# Shared frontmatter extraction helpers (reduces code duplication) + +# Check if file has YAML frontmatter (starts with ---) +has_frontmatter() { + local file="$1" + if [ ! -f "$file" ]; then + return 1 + fi + head -1 "$file" 2>/dev/null | grep -q '^---$' +} + +# Extract a field from YAML frontmatter +# Usage: extract_frontmatter_field "file.md" "description" +# Returns: field value on stdout, exit code 0 on success +# Note: Returns empty string (not error) if field not found - this is intentional +extract_frontmatter_field() { + local file="$1" + local field="$2" + local strip_quotes="${3:-true}" + + if [ ! -f "$file" ]; then + print_error "File not found: $file" + return 1 + fi + + local result + if ! result=$(awk -v field="$field" -v strip="$strip_quotes" ' + /^---$/ { if (in_front) exit; in_front=1; next } + in_front && $0 ~ "^" field ":" { + sub("^" field ":[[:space:]]*", "") + if (strip == "true") { + gsub(/^["'"'"']|["'"'"']$/, "") + } + print + exit + } + ' "$file" 2>&1); then + print_error "AWK parsing failed for $file: $result" + return 1 + fi + + printf '%s' "$result" +} + +# Extract body content (everything after YAML frontmatter) +# Returns: body content on stdout, exit code 0 on success +extract_body() { + local file="$1" + + if [ ! -f "$file" ]; then + print_error "File not found: $file" + return 1 + fi + + # Run AWK directly to preserve output formatting (including newlines) + awk ' + /^---$/ { count++; if (count == 2) { getbody=1; next } } + getbody { print } + ' "$file" +} + +# Convert markdown command to Gemini TOML format +convert_md_to_toml() { + local md_file="$1" + local toml_file="$2" + + # Extract description from YAML frontmatter (escape quotes for TOML) + local description + description=$(extract_frontmatter_field "$md_file" "description" "false" | sed 's/"/\\"/g') + + # Get body (everything after second ---) + local body + body=$(extract_body "$md_file") + + # Replace $ARGUMENTS with {{args}} + # Use printf instead of echo to safely handle arbitrary input (avoids command injection) + body=$(printf '%s\n' "$body" | sed 's/\$ARGUMENTS/{{args}}/g') + + # Write TOML file + { + if [ -n "$description" ]; then + echo "description = \"$description\"" + echo "" + fi + echo "prompt = \"\"\"" + echo "$body" + echo "\"\"\"" + } > "$toml_file" +} + +# Convert markdown command to Codex custom prompt format +# Input: plain markdown or markdown with YAML frontmatter +# Output: markdown with required YAML frontmatter (description, argument-hint) +convert_md_to_codex_prompt() { + local md_file="$1" + local prompt_file="$2" + + local description="" + local body="" + + if has_frontmatter "$md_file"; then + # Extract existing description using shared helper + description=$(extract_frontmatter_field "$md_file" "description") + # Get body using shared helper + body=$(extract_body "$md_file") else + # No frontmatter - use filename as basis for description + local basename + basename=$(basename "$md_file" .md) + description=$(echo "$basename" | tr '-' ' ' | sed 's/\b\(.\)/\u\1/g') + if ! body=$(safe_read_file "$md_file"); then + return 1 + fi + fi + + # If still no description, extract from first line of body + if [ -z "$description" ]; then + description=$(printf '%s\n' "$body" | head -1 | sed 's/^#* *//' | cut -c1-80) + fi + + # Detect if command uses arguments (look for placeholders like $1, $FILE, etc.) + local argument_hint="" + if printf '%s\n' "$body" | grep -qE '\$[0-9]|\$[A-Z_]+'; then + # Extract named placeholders + local placeholders + placeholders=$(printf '%s\n' "$body" | grep -oE '\$[A-Z_]+' | sort -u | tr '\n' ' ') + if [ -n "$placeholders" ]; then + argument_hint=$(echo "$placeholders" | sed 's/\$\([A-Z_]*\)/\1=/g' | tr -s ' ') + fi + fi + + # Write Codex prompt file with frontmatter + { + echo "---" + echo "description: $description" + if [ -n "$argument_hint" ]; then + echo "argument-hint: $argument_hint" + fi + echo "---" echo "" + echo "$body" + } > "$prompt_file" +} + +# Convert markdown command to Windsurf workflow format +# Input: plain markdown or markdown with YAML frontmatter +# Output: markdown structured as workflow (title, description, numbered steps) +convert_md_to_windsurf_workflow() { + local md_file="$1" + local workflow_file="$2" + + local name="" + local description="" + local body="" + + if has_frontmatter "$md_file"; then + # Extract name and description using shared helpers + name=$(extract_frontmatter_field "$md_file" "name") + description=$(extract_frontmatter_field "$md_file" "description") + # Get body using shared helper + body=$(extract_body "$md_file") + else + # No frontmatter - derive from filename + local basename + basename=$(basename "$md_file" .md) + name=$(echo "$basename" | tr '-' ' ' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1') + if ! body=$(safe_read_file "$md_file"); then + return 1 + fi + fi + + # If still no name, extract from first heading + if [ -z "$name" ]; then + name=$(printf '%s\n' "$body" | grep -m1 '^#' | sed 's/^#* *//') fi + + # If still no description, use first non-heading line + if [ -z "$description" ]; then + description=$(printf '%s\n' "$body" | grep -v '^#' | grep -v '^$' | head -1 | cut -c1-100) + fi + + # Check content length (Windsurf has 12000 char limit) + local content_length + content_length=$(printf '%s' "$body" | wc -c) + if [ "$content_length" -gt "$WINDSURF_CHAR_LIMIT" ]; then + print_info "Warning: $md_file exceeds Windsurf 12k char limit, truncating" + body=$(printf '%s' "$body" | head -c "$WINDSURF_CHAR_LIMIT") + body="$body"$'\n\n'"[Content truncated - see original skill for full instructions]" + fi + + # Check if body already has numbered steps structure + local has_steps + has_steps=$(printf '%s\n' "$body" | grep -cE '^[0-9]+\.' || true) + + # Write Windsurf workflow file + { + echo "# $name" + echo "" + if [ -n "$description" ]; then + echo "$description" + echo "" + fi + + if [ "$has_steps" -gt 0 ]; then + # Already has numbered steps, use as-is + echo "$body" + else + # Wrap content as workflow instructions + echo "## Instructions" + echo "" + echo "$body" + fi + } > "$workflow_file" +} + +# ============================================================================ +# Agent Detection +# ============================================================================ + +detect_agent() { + # Agent detection: directory -> agent name mapping + # Using loop for maintainability (easier to add new agents) + local agents=".gemini:gemini .github:copilot .cursor:cursor .opencode:opencode .amp:amp .goose:goose .factory:factory .codex:codex .windsurf:windsurf" + + for entry in $agents; do + local dir="${entry%%:*}" + local agent="${entry#*:}" + if [ -d "$dir" ]; then + echo "$agent" + return 0 + fi + done + + echo "" } ask_agent() { @@ -181,19 +481,19 @@ ask_agent() { echo "Which AI coding agent are you using?" echo "" - echo " ┌─────────────┬──────────────────┐" - echo " │ Agent │ Directory │" - echo " ├─────────────┼──────────────────┤" - echo " │ 1) Gemini │ .gemini/skills │" - echo " │ 2) Copilot │ .github/skills │" - echo " │ 3) Cursor │ .cursor/skills │" - echo " │ 4) OpenCode │ .opencode/skill │" - echo " │ 5) Amp │ .amp/skills │" - echo " │ 6) Goose │ .goose/skills │" - echo " │ 7) Factory │ .factory/skills │" - echo " └─────────────┴──────────────────┘" - echo "" - echo " Claude Code? Use: claude plugins add github:plaited/marketplace" + echo " ┌─────────────┬────────────────────┐" + echo " │ Agent │ Directory │" + echo " ├─────────────┼────────────────────┤" + echo " │ 1) Gemini │ .gemini/skills │" + echo " │ 2) Copilot │ .github/skills │" + echo " │ 3) Cursor │ .cursor/skills │" + echo " │ 4) OpenCode │ .opencode/skill │" + echo " │ 5) Amp │ .amp/skills │" + echo " │ 6) Goose │ .goose/skills │" + echo " │ 7) Factory │ .factory/skills │" + echo " │ 8) Codex │ .codex/skills │" + echo " │ 9) Windsurf │ .windsurf/skills │" + echo " └─────────────┴────────────────────┘" echo "" if [ -n "$detected" ]; then @@ -201,7 +501,7 @@ ask_agent() { echo "" fi - printf "Select agent [1-7]: " + printf "Select agent [1-9]: " read choice case "$choice" in @@ -212,6 +512,8 @@ ask_agent() { 5) echo "amp" ;; 6) echo "goose" ;; 7) echo "factory" ;; + 8) echo "codex" ;; + 9) echo "windsurf" ;; *) print_error "Invalid choice" exit 1 @@ -223,55 +525,121 @@ ask_agent() { # Installation Functions # ============================================================================ -clone_plugin() { - local plugin_name="$1" +clone_project() { + local project_name="$1" local source="$2" # Parse source into repo URL and sparse path - read repo_url sparse_path <<< "$(parse_source "$source")" + local parse_result + if ! parse_result=$(parse_source "$source"); then + return 1 + fi + read repo_url sparse_path <<< "$parse_result" - print_info "Cloning $plugin_name from $repo_url..." + print_info "Cloning $project_name from $repo_url..." - local plugin_temp="$TEMP_DIR/$plugin_name" - mkdir -p "$plugin_temp" + local project_temp="$TEMP_DIR/$project_name" + mkdir -p "$project_temp" - git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$plugin_temp" --branch "$BRANCH" 2>/dev/null + local clone_output + if ! clone_output=$(git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$project_temp" --branch "$BRANCH" 2>&1); then + print_error "Failed to clone $project_name from $repo_url" + print_error "Git error: $clone_output" + rm -rf "$project_temp" + return 1 + fi + + cd "$project_temp" || { + print_error "Failed to access cloned directory: $project_temp" + return 1 + } - cd "$plugin_temp" # Fetch both skills and commands directories - git sparse-checkout set "$sparse_path/skills" "$sparse_path/commands" 2>/dev/null + local sparse_output + if ! sparse_output=$(git sparse-checkout set "$sparse_path/skills" "$sparse_path/commands" 2>&1); then + print_error "Failed to set sparse checkout for $project_name" + print_error "Git error: $sparse_output" + cd - > /dev/null + rm -rf "$project_temp" + return 1 + fi cd - > /dev/null # Store the sparse path for later use - echo "$sparse_path" > "$plugin_temp/.sparse_path" + printf '%s' "$sparse_path" > "$project_temp/.sparse_path" - print_success "Cloned $plugin_name" + print_success "Cloned $project_name" } -install_plugin() { - local plugin_name="$1" +install_project() { + local project_name="$1" local skills_dir="$2" local commands_dir="$3" + local agent="$4" - local plugin_temp="$TEMP_DIR/$plugin_name" + local project_temp="$TEMP_DIR/$project_name" local sparse_path - sparse_path=$(cat "$plugin_temp/.sparse_path") + sparse_path=$(cat "$project_temp/.sparse_path") - local source_skills="$plugin_temp/$sparse_path/skills" - local source_commands="$plugin_temp/$sparse_path/commands" + local source_skills="$project_temp/$sparse_path/skills" + local source_commands="$project_temp/$sparse_path/commands" if [ -d "$source_skills" ]; then - print_info "Installing skills from $plugin_name..." + print_info "Installing skills from $project_name..." cp -r "$source_skills/"* "$skills_dir/" - print_success "Installed $plugin_name skills" + print_success "Installed $project_name skills" else - print_info "No skills directory in $plugin_name ($sparse_path/skills)" + print_info "No skills directory in $project_name ($sparse_path/skills)" fi - if [ -n "$commands_dir" ] && [ -d "$source_commands" ]; then - print_info "Installing commands from $plugin_name..." - cp -r "$source_commands/"* "$commands_dir/" - print_success "Installed $plugin_name commands" + if [ -d "$source_commands" ]; then + print_info "Installing commands from $project_name..." + + case "$agent" in + gemini) + # Convert .md to .toml for Gemini CLI + for md_file in "$source_commands"/*.md; do + [ -f "$md_file" ] || continue + local basename + basename=$(basename "$md_file" .md) + convert_md_to_toml "$md_file" "$commands_dir/$basename.toml" + done + print_success "Converted and installed $project_name commands (TOML)" + ;; + + codex) + # Convert to Codex custom prompts (user-scoped ~/.codex/prompts/) + local prompts_dir + prompts_dir=$(get_prompts_dir "$agent") + mkdir -p "$prompts_dir" + for md_file in "$source_commands"/*.md; do + [ -f "$md_file" ] || continue + local basename + basename=$(basename "$md_file" .md) + convert_md_to_codex_prompt "$md_file" "$prompts_dir/$basename.md" + done + print_success "Converted and installed $project_name prompts → $prompts_dir/" + ;; + + windsurf) + # Convert to Windsurf workflows + for md_file in "$source_commands"/*.md; do + [ -f "$md_file" ] || continue + local basename + basename=$(basename "$md_file" .md) + convert_md_to_windsurf_workflow "$md_file" "$commands_dir/$basename.md" + done + print_success "Converted and installed $project_name workflows" + ;; + + *) + # Direct copy for agents with native command support + if [ -n "$commands_dir" ]; then + cp -r "$source_commands/"* "$commands_dir/" + print_success "Installed $project_name commands" + fi + ;; + esac fi } @@ -281,7 +649,7 @@ install_plugin() { do_install() { local agent="$1" - local specific_plugin="$2" + local specific_project="$2" local skills_dir commands_dir skills_dir=$(get_skills_dir "$agent") @@ -305,36 +673,38 @@ do_install() { mkdir -p "$commands_dir" fi - local plugins_installed=0 + local projects_installed=0 - # Get all plugin names - local plugin_names - plugin_names=$(get_plugin_names) + # Get all project names + local project_names + project_names=$(get_project_names) - for plugin_name in $plugin_names; do - # Skip if specific plugin requested and this isn't it - if [ -n "$specific_plugin" ] && [ "$plugin_name" != "$specific_plugin" ]; then + for project_name in $project_names; do + # Skip if specific project requested and this isn't it + if [ -n "$specific_project" ] && [ "$project_name" != "$specific_project" ]; then continue fi local source - source=$(get_plugin_source "$plugin_name") + source=$(get_project_repo "$project_name") if [ -z "$source" ]; then - print_error "Could not find source for $plugin_name" + print_error "Could not find source for $project_name" continue fi - clone_plugin "$plugin_name" "$source" - install_plugin "$plugin_name" "$skills_dir" "$commands_dir" - plugins_installed=$((plugins_installed + 1)) + if ! clone_project "$project_name" "$source"; then + continue + fi + install_project "$project_name" "$skills_dir" "$commands_dir" "$agent" + projects_installed=$((projects_installed + 1)) done - if [ "$plugins_installed" -eq 0 ]; then - if [ -n "$specific_plugin" ]; then - print_error "Plugin not found: $specific_plugin" + if [ "$projects_installed" -eq 0 ]; then + if [ -n "$specific_project" ]; then + print_error "Project not found: $specific_project" else - print_error "No plugins installed" + print_error "No projects installed" fi return 1 fi @@ -345,45 +715,63 @@ do_install() { echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo " Agent: $agent" - echo " Plugins installed: $plugins_installed" + echo " Projects installed: $projects_installed" echo "" echo " Locations:" echo " • Skills: $skills_dir/" - if supports_commands "$agent" && [ -d "$commands_dir" ] && [ "$(ls -A "$commands_dir" 2>/dev/null)" ]; then - echo " • Commands: $commands_dir/" - fi + + # Show commands/prompts/workflows location based on agent + case "$agent" in + codex) + local prompts_dir + prompts_dir=$(get_prompts_dir "$agent") + if [ -d "$prompts_dir" ] && [ "$(ls -A "$prompts_dir" 2>/dev/null)" ]; then + echo " • Prompts: $prompts_dir/ (converted from commands)" + fi + ;; + windsurf) + if [ -d "$commands_dir" ] && [ "$(ls -A "$commands_dir" 2>/dev/null)" ]; then + echo " • Workflows: $commands_dir/ (converted from commands)" + fi + ;; + *) + if supports_commands "$agent" && [ -d "$commands_dir" ] && [ "$(ls -A "$commands_dir" 2>/dev/null)" ]; then + echo " • Commands: $commands_dir/" + fi + ;; + esac + echo "" echo " Next steps:" echo " 1. Restart your AI coding agent to load the new skills" echo " 2. Skills are auto-discovered and activated when relevant" + if [ "$agent" = "codex" ]; then + echo " 3. Prompts are invoked via /prompts: in Codex" + elif [ "$agent" = "windsurf" ]; then + echo " 3. Workflows are invoked via / in Cascade" + fi echo "" } # ============================================================================ -# List Plugins +# List Projects # ============================================================================ do_list() { echo "" - echo "Available Plugins:" + echo "Available Projects:" echo "" - echo " ┌────────────────────┬────────────────────────────────────────────────────┐" - echo " │ Plugin │ Description │" - echo " ├────────────────────┼────────────────────────────────────────────────────┤" - local plugin_names - plugin_names=$(get_plugin_names) + local project_names + project_names=$(get_project_names) - for plugin_name in $plugin_names; do - local description - description=$(get_plugin_description "$plugin_name") - printf " │ %-18s │ %-50s │\n" "$plugin_name" "${description:0:50}" + for project_name in $project_names; do + echo " - $project_name" done - echo " └────────────────────┴────────────────────────────────────────────────────┘" echo "" - echo "Install a specific plugin:" - echo " ./install.sh --agent gemini --plugin typescript-lsp" + echo "Install a specific project:" + echo " ./install.sh --agent gemini --project development-skills" echo "" } @@ -392,7 +780,7 @@ do_list() { # ============================================================================ do_update() { - local specific_plugin="$1" + local specific_project="$1" local agent agent=$(detect_agent) @@ -407,22 +795,22 @@ do_update() { local skills_dir skills_dir=$(get_skills_dir "$agent") - # Get all plugin names and remove their skill directories - local plugin_names - plugin_names=$(get_plugin_names) + # Get all project names and remove their skill directories + local project_names + project_names=$(get_project_names) - for plugin_name in $plugin_names; do - if [ -n "$specific_plugin" ] && [ "$plugin_name" != "$specific_plugin" ]; then + for project_name in $project_names; do + if [ -n "$specific_project" ] && [ "$project_name" != "$specific_project" ]; then continue fi - # Each plugin may install multiple skills - we need to check what was installed + # Each project may install multiple skills - we need to check what was installed # For now, remove known skill directories - [ -d "$skills_dir/$plugin_name" ] && rm -rf "$skills_dir/$plugin_name" + [ -d "$skills_dir/$project_name" ] && rm -rf "$skills_dir/$project_name" done # Reinstall - do_install "$agent" "$specific_plugin" + do_install "$agent" "$specific_project" } # ============================================================================ @@ -430,7 +818,7 @@ do_update() { # ============================================================================ do_uninstall() { - local specific_plugin="$1" + local specific_project="$1" local agent agent=$(detect_agent) @@ -444,27 +832,27 @@ do_uninstall() { local skills_dir skills_dir=$(get_skills_dir "$agent") - local plugin_names - plugin_names=$(get_plugin_names) + local project_names + project_names=$(get_project_names) local removed=0 - for plugin_name in $plugin_names; do - if [ -n "$specific_plugin" ] && [ "$plugin_name" != "$specific_plugin" ]; then + for project_name in $project_names; do + if [ -n "$specific_project" ] && [ "$project_name" != "$specific_project" ]; then continue fi - if [ -d "$skills_dir/$plugin_name" ]; then - rm -rf "$skills_dir/$plugin_name" - print_success "Removed $skills_dir/$plugin_name/" + if [ -d "$skills_dir/$project_name" ]; then + rm -rf "$skills_dir/$project_name" + print_success "Removed $skills_dir/$project_name/" removed=$((removed + 1)) fi done if [ "$removed" -eq 0 ]; then - print_info "No Plaited plugins found in $skills_dir/" + print_info "No Plaited projects found in $skills_dir/" else echo "" - print_success "Uninstalled $removed plugin(s)" + print_success "Uninstalled $removed project(s)" fi } @@ -475,45 +863,44 @@ do_uninstall() { show_help() { echo "Usage: install.sh [OPTIONS]" echo "" - echo "Install Plaited plugins for AI coding agents supporting agent-skills-spec." - echo "" - echo "NOTE: Claude Code users should use the plugin marketplace instead:" - echo " claude plugins add github:plaited/marketplace" + echo "Install Plaited skills for AI coding agents supporting agent-skills-spec." echo "" echo "Options:" echo " --agent Install for specific agent" - echo " --plugin Install specific plugin only" - echo " --list List available plugins" + echo " --project Install specific project only" + echo " --list List available projects" echo " --update Update existing installation" echo " --uninstall Remove installation" echo " --help Show this help message" echo "" echo "Supported Agents:" echo "" - echo " ┌─────────────┬──────────────────────┬──────────────────────┐" - echo " │ Agent │ Skills │ Commands │" - echo " ├─────────────┼──────────────────────┼──────────────────────┤" - echo " │ gemini │ .gemini/skills │ - │" - echo " │ copilot │ .github/skills │ - │" - echo " │ cursor │ .cursor/skills │ .cursor/commands │" - echo " │ opencode │ .opencode/skill │ .opencode/command │" - echo " │ amp │ .amp/skills │ .amp/commands │" - echo " │ goose │ .goose/skills │ - │" - echo " │ factory │ .factory/skills │ .factory/commands │" - echo " └─────────────┴──────────────────────┴──────────────────────┘" + echo " ┌─────────────┬──────────────────────┬────────────────────────────┐" + echo " │ Agent │ Skills │ Commands │" + echo " ├─────────────┼──────────────────────┼────────────────────────────┤" + echo " │ gemini │ .gemini/skills │ .gemini/commands (→TOML) │" + echo " │ copilot │ .github/skills │ - │" + echo " │ cursor │ .cursor/skills │ .cursor/commands │" + echo " │ opencode │ .opencode/skill │ .opencode/command │" + echo " │ amp │ .amp/skills │ .amp/commands │" + echo " │ goose │ .goose/skills │ - │" + echo " │ factory │ .factory/skills │ .factory/commands │" + echo " │ codex │ .codex/skills │ ~/.codex/prompts (→prompt) │" + echo " │ windsurf │ .windsurf/skills │ .windsurf/workflows │" + echo " └─────────────┴──────────────────────┴────────────────────────────┘" echo "" echo "Examples:" echo " ./install.sh # Interactive mode" echo " ./install.sh --agent gemini # Install all for Gemini" - echo " ./install.sh --agent cursor --plugin typescript-lsp" - echo " ./install.sh --list # List available plugins" + echo " ./install.sh --agent cursor --project development-skills" + echo " ./install.sh --list # List available projects" echo " ./install.sh --update # Update existing" echo " ./install.sh --uninstall # Remove all" } main() { local agent="" - local plugin="" + local project="" local action="install" while [ $# -gt 0 ]; do @@ -522,8 +909,8 @@ main() { agent="$2" shift 2 ;; - --plugin) - plugin="$2" + --project) + project="$2" shift 2 ;; --list) @@ -550,14 +937,50 @@ main() { esac done - # Check marketplace.json exists, fetch from GitHub if not found (for curl | bash usage) - if [ ! -f "$MARKETPLACE_JSON" ]; then - MARKETPLACE_JSON=$(mktemp) - curl -fsSL "https://raw.githubusercontent.com/plaited/marketplace/main/.claude-plugin/marketplace.json" -o "$MARKETPLACE_JSON" 2>/dev/null - if [ ! -s "$MARKETPLACE_JSON" ]; then - print_error "Could not fetch marketplace.json" + # Check projects.json exists, fetch from GitHub if not found (for curl | bash usage) + if [ ! -f "$PROJECTS_JSON" ]; then + PROJECTS_JSON=$(mktemp) + TEMP_PROJECTS_JSON="$PROJECTS_JSON" # Mark for cleanup on exit + local checksum_file + checksum_file=$(mktemp) + + # Fetch both projects.json and its checksum + curl -fsSL "https://raw.githubusercontent.com/plaited/skills-installer/main/projects.json" -o "$PROJECTS_JSON" 2>/dev/null + curl -fsSL "https://raw.githubusercontent.com/plaited/skills-installer/main/projects.json.sha256" -o "$checksum_file" 2>/dev/null + + if [ ! -s "$PROJECTS_JSON" ]; then + print_error "Could not fetch projects.json" + rm -f "$checksum_file" + exit 1 + fi + + # Verify checksum (mandatory - security: prevent tampered downloads) + if [ ! -s "$checksum_file" ]; then + print_error "Could not fetch checksum file - cannot verify projects.json integrity" + print_error "This is a security requirement. Aborting." + rm -f "$PROJECTS_JSON" exit 1 fi + + local expected_checksum actual_checksum + expected_checksum=$(awk '{print $1}' "$checksum_file") + actual_checksum=$(shasum -a 256 "$PROJECTS_JSON" 2>/dev/null | awk '{print $1}') + + if [ -z "$actual_checksum" ]; then + print_error "Could not compute checksum - shasum not available" + rm -f "$checksum_file" "$PROJECTS_JSON" + exit 1 + fi + + if [ "$expected_checksum" != "$actual_checksum" ]; then + print_error "Checksum verification failed for projects.json" + print_error "Expected: $expected_checksum" + print_error "Got: $actual_checksum" + rm -f "$checksum_file" "$PROJECTS_JSON" + exit 1 + fi + print_info "Checksum verified for projects.json" + rm -f "$checksum_file" fi print_header @@ -571,33 +994,22 @@ main() { agent=$(ask_agent) fi - # Redirect Claude users to marketplace - if [ "$agent" = "claude" ]; then - echo "" - print_info "Claude Code users should use the plugin marketplace:" - echo "" - echo " claude plugins add github:plaited/marketplace" - echo "" - exit 0 - fi - # Validate agent local skills_dir skills_dir=$(get_skills_dir "$agent") if [ -z "$skills_dir" ]; then print_error "Unknown agent: $agent" - print_info "Valid agents: gemini, copilot, cursor, opencode, amp, goose, factory" - print_info "Claude Code? Use: claude plugins add github:plaited/marketplace" + print_info "Valid agents: gemini, copilot, cursor, opencode, amp, goose, factory, codex, windsurf" exit 1 fi - do_install "$agent" "$plugin" + do_install "$agent" "$project" ;; update) - do_update "$plugin" + do_update "$project" ;; uninstall) - do_uninstall "$plugin" + do_uninstall "$project" ;; esac } diff --git a/install.spec.ts b/install.spec.ts index 223252e..7378fe5 100644 --- a/install.spec.ts +++ b/install.spec.ts @@ -5,7 +5,7 @@ import { join } from "path"; const SCRIPT_DIR = import.meta.dir; const INSTALL_SCRIPT = join(SCRIPT_DIR, "install.sh"); -const MARKETPLACE_JSON = join(SCRIPT_DIR, ".claude-plugin/marketplace.json"); +const PROJECTS_JSON = join(SCRIPT_DIR, "projects.json"); const README_PATH = join(SCRIPT_DIR, "README.md"); // Helper to run bash functions from install.sh @@ -31,7 +31,7 @@ async function callFunction(fn: string, ...args: string[]): Promise { // Create a wrapper script that sources only the function definitions const script = ` set -e -MARKETPLACE_JSON="${MARKETPLACE_JSON}" +PROJECTS_JSON="${PROJECTS_JSON}" # Define functions inline (extracted from install.sh) get_skills_dir() { @@ -43,6 +43,8 @@ get_skills_dir() { amp) echo ".amp/skills" ;; goose) echo ".goose/skills" ;; factory) echo ".factory/skills" ;; + codex) echo ".codex/skills" ;; + windsurf) echo ".windsurf/skills" ;; *) echo "" ;; esac } @@ -56,13 +58,30 @@ get_commands_dir() { amp) echo ".amp/commands" ;; goose) echo ".goose/commands" ;; factory) echo ".factory/commands" ;; + codex) echo "" ;; + windsurf) echo ".windsurf/workflows" ;; *) echo "" ;; esac } +get_prompts_dir() { + case "$1" in + codex) echo "$HOME/.codex/prompts" ;; + *) echo "" ;; + esac +} + supports_commands() { case "$1" in - cursor|opencode|amp|factory) return 0 ;; + gemini|cursor|opencode|amp|factory) return 0 ;; + codex|windsurf) return 0 ;; + *) return 1 ;; + esac +} + +needs_command_conversion() { + case "$1" in + gemini|codex|windsurf) return 0 ;; *) return 1 ;; esac } @@ -72,45 +91,28 @@ parse_source() { echo "https://github.com/$repo.git" ".claude" } -get_plugin_names() { +get_project_names() { awk ' - /"plugins"[[:space:]]*:/ { in_plugins=1 } - in_plugins && /"name"[[:space:]]*:/ { + /"projects"[[:space:]]*:/ { in_projects=1 } + in_projects && /"name"[[:space:]]*:/ { gsub(/.*"name"[[:space:]]*:[[:space:]]*"/, "") gsub(/".*/, "") - name=$0 - } - in_plugins && /"source"[[:space:]]*:/ && name { - print name - name="" + print } - ' "$MARKETPLACE_JSON" + ' "$PROJECTS_JSON" } -get_plugin_source() { - local plugin_name="$1" +get_project_repo() { + local project_name="$1" awk ' - /"name"[[:space:]]*:[[:space:]]*"'"$plugin_name"'"/ { found=1 } + /"name"[[:space:]]*:[[:space:]]*"'"$project_name"'"/ { found=1 } found && /"repo"[[:space:]]*:/ { gsub(/.*"repo"[[:space:]]*:[[:space:]]*"/, "") gsub(/".*/, "") print exit } - ' "$MARKETPLACE_JSON" -} - -get_plugin_description() { - local plugin_name="$1" - awk ' - /"name"[[:space:]]*:[[:space:]]*"'"$plugin_name"'"/ { found=1 } - found && /"description"[[:space:]]*:/ { - gsub(/.*"description"[[:space:]]*:[[:space:]]*"/, "") - gsub(/".*/, "") - print - exit - } - ' "$MARKETPLACE_JSON" + ' "$PROJECTS_JSON" } ${fn} ${quotedArgs} @@ -128,7 +130,15 @@ async function callFunctionExitCode( const script = ` supports_commands() { case "$1" in - cursor|opencode|amp|factory) return 0 ;; + gemini|cursor|opencode|amp|factory) return 0 ;; + codex|windsurf) return 0 ;; + *) return 1 ;; + esac +} + +needs_command_conversion() { + case "$1" in + gemini|codex|windsurf) return 0 ;; *) return 1 ;; esac } @@ -142,80 +152,50 @@ ${fn} ${quotedArgs} } } -describe("marketplace.json", () => { - let marketplace: { - name: string; - metadata?: { description: string }; - owner: { name: string }; - plugins: Array<{ +describe("projects.json", () => { + let projects: { + projects: Array<{ name: string; - description: string; - source: { source: string; repo: string }; - category: string; - keywords?: string[]; + repo: string; }>; }; beforeAll(async () => { - const content = await readFile(MARKETPLACE_JSON, "utf-8"); - marketplace = JSON.parse(content); + const content = await readFile(PROJECTS_JSON, "utf-8"); + projects = JSON.parse(content); }); test("is valid JSON", async () => { - const content = await readFile(MARKETPLACE_JSON, "utf-8"); + const content = await readFile(PROJECTS_JSON, "utf-8"); expect(() => JSON.parse(content)).not.toThrow(); }); test("has required top-level fields", () => { - expect(marketplace.name).toBeDefined(); - expect(marketplace.owner).toBeDefined(); - expect(marketplace.owner.name).toBeDefined(); - expect(marketplace.plugins).toBeDefined(); - expect(Array.isArray(marketplace.plugins)).toBe(true); - }); - - test("plugins have required fields", () => { - for (const plugin of marketplace.plugins) { - expect(plugin.name).toBeDefined(); - expect(typeof plugin.name).toBe("string"); - expect(plugin.name.length).toBeGreaterThan(0); - - expect(plugin.description).toBeDefined(); - expect(typeof plugin.description).toBe("string"); - - expect(plugin.source).toBeDefined(); - expect(typeof plugin.source).toBe("object"); - expect(plugin.source.source).toBe("github"); - expect(plugin.source.repo).toBeDefined(); - expect(typeof plugin.source.repo).toBe("string"); - - expect(plugin.category).toBeDefined(); - expect(typeof plugin.category).toBe("string"); - } + expect(projects.projects).toBeDefined(); + expect(Array.isArray(projects.projects)).toBe(true); }); - test("plugins have keywords array", () => { - for (const plugin of marketplace.plugins) { - expect(plugin.keywords).toBeDefined(); - expect(Array.isArray(plugin.keywords)).toBe(true); - expect(plugin.keywords!.length).toBeGreaterThan(0); - for (const keyword of plugin.keywords!) { - expect(typeof keyword).toBe("string"); - } + test("projects have required fields", () => { + for (const project of projects.projects) { + expect(project.name).toBeDefined(); + expect(typeof project.name).toBe("string"); + expect(project.name.length).toBeGreaterThan(0); + + expect(project.repo).toBeDefined(); + expect(typeof project.repo).toBe("string"); } }); - test("plugin names are unique", () => { - const names = marketplace.plugins.map((p) => p.name); + test("project names are unique", () => { + const names = projects.projects.map((p) => p.name); const uniqueNames = new Set(names); expect(uniqueNames.size).toBe(names.length); }); - test("plugin sources are valid github format", () => { + test("project repos are valid github format", () => { const repoRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/; - for (const plugin of marketplace.plugins) { - expect(plugin.source.source).toBe("github"); - expect(plugin.source.repo).toMatch(repoRegex); + for (const project of projects.projects) { + expect(project.repo).toMatch(repoRegex); } }); }); @@ -229,6 +209,8 @@ describe("install.sh - get_skills_dir", () => { amp: ".amp/skills", goose: ".goose/skills", factory: ".factory/skills", + codex: ".codex/skills", + windsurf: ".windsurf/skills", }; for (const [agent, expectedDir] of Object.entries(expectedMappings)) { @@ -253,6 +235,7 @@ describe("install.sh - get_commands_dir", () => { amp: ".amp/commands", goose: ".goose/commands", factory: ".factory/commands", + windsurf: ".windsurf/workflows", // Windsurf uses workflows }; for (const [agent, expectedDir] of Object.entries(expectedMappings)) { @@ -262,15 +245,43 @@ describe("install.sh - get_commands_dir", () => { }); } + test("returns empty for codex (uses prompts_dir instead)", async () => { + const result = await callFunction("get_commands_dir", "codex"); + expect(result).toBe(""); + }); + test("returns empty for unknown agent", async () => { const result = await callFunction("get_commands_dir", "unknown"); expect(result).toBe(""); }); }); +describe("install.sh - get_prompts_dir", () => { + test("returns ~/.codex/prompts for codex", async () => { + const result = await callFunction("get_prompts_dir", "codex"); + expect(result).toContain(".codex/prompts"); + }); + + test("returns empty for other agents", async () => { + const agents = ["gemini", "cursor", "windsurf", "copilot"]; + for (const agent of agents) { + const result = await callFunction("get_prompts_dir", agent); + expect(result).toBe(""); + } + }); +}); + describe("install.sh - supports_commands", () => { - const supportsCommands = ["cursor", "opencode", "amp", "factory"]; - const doesNotSupportCommands = ["gemini", "copilot", "goose"]; + const supportsCommands = [ + "gemini", + "cursor", + "opencode", + "amp", + "factory", + "codex", + "windsurf", + ]; + const doesNotSupportCommands = ["copilot", "goose"]; for (const agent of supportsCommands) { test(`${agent} supports commands`, async () => { @@ -287,6 +298,25 @@ describe("install.sh - supports_commands", () => { } }); +describe("install.sh - needs_command_conversion", () => { + const needsConversion = ["gemini", "codex", "windsurf"]; + const noConversion = ["cursor", "opencode", "amp", "factory", "copilot", "goose"]; + + for (const agent of needsConversion) { + test(`${agent} needs command conversion`, async () => { + const exitCode = await callFunctionExitCode("needs_command_conversion", agent); + expect(exitCode).toBe(0); + }); + } + + for (const agent of noConversion) { + test(`${agent} does not need command conversion`, async () => { + const exitCode = await callFunctionExitCode("needs_command_conversion", agent); + expect(exitCode).toBe(1); + }); + } +}); + describe("install.sh - parse_source", () => { test("parses repo and always uses .claude", async () => { const result = await callFunction( @@ -305,37 +335,115 @@ describe("install.sh - parse_source", () => { }); }); +describe("install.sh - security validation", () => { + // Helper to run parse_source from actual install.sh (with security checks) + async function runParseSource(repo: string): Promise<{ stdout: string; exitCode: number }> { + const script = ` +set -e +# Define print_error stub +print_error() { echo "ERROR: $1" >&2; } + +# Define parse_source with security validation (copied from install.sh) +parse_source() { + local repo="$1" + + # Validate against path traversal attacks + if [[ "$repo" =~ \\.\\. ]]; then + print_error "Invalid repository path (path traversal detected): $repo" + return 1 + fi + + # Validate repo format (owner/repo) + if ! [[ "$repo" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then + print_error "Invalid repository format: $repo (expected: owner/repo)" + return 1 + fi + + echo "https://github.com/$repo.git" ".claude" +} + +parse_source "${repo}" +`; + try { + const result = await $`bash -c ${script.replace("${repo}", repo)}`.quiet(); + return { stdout: result.text().trim(), exitCode: 0 }; + } catch (error: unknown) { + const err = error as { stdout?: { toString(): string }; exitCode?: number }; + return { + stdout: err.stdout?.toString().trim() ?? "", + exitCode: err.exitCode ?? 1, + }; + } + } + + test("rejects path traversal with ..", async () => { + const result = await runParseSource("../../../etc/passwd"); + expect(result.exitCode).toBe(1); + }); + + test("rejects path traversal in middle of path", async () => { + const result = await runParseSource("owner/../../../etc/passwd"); + expect(result.exitCode).toBe(1); + }); + + test("rejects path traversal with encoded dots", async () => { + const result = await runParseSource("owner/..repo"); + expect(result.exitCode).toBe(1); + }); + + test("rejects invalid repo format - no slash", async () => { + const result = await runParseSource("invalid-repo-no-slash"); + expect(result.exitCode).toBe(1); + }); + + test("rejects invalid repo format - multiple slashes", async () => { + const result = await runParseSource("owner/repo/extra"); + expect(result.exitCode).toBe(1); + }); + + test("rejects invalid characters in repo", async () => { + const result = await runParseSource("owner/repo;echo hacked"); + expect(result.exitCode).toBe(1); + }); + + test("accepts valid repo format", async () => { + const result = await runParseSource("valid-owner/valid-repo"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("https://github.com/valid-owner/valid-repo.git"); + }); + + test("accepts repo with dots (not traversal)", async () => { + const result = await runParseSource("owner/repo.js"); + expect(result.exitCode).toBe(0); + }); + + test("accepts repo with underscores and hyphens", async () => { + const result = await runParseSource("my_owner/my-repo_name"); + expect(result.exitCode).toBe(0); + }); +}); + describe("install.sh - JSON parsing functions", () => { - test("get_plugin_names returns all plugin names", async () => { - const result = await callFunction("get_plugin_names"); + test("get_project_names returns all project names", async () => { + const result = await callFunction("get_project_names"); const names = result.split("\n").filter(Boolean); - const content = await readFile(MARKETPLACE_JSON, "utf-8"); - const marketplace = JSON.parse(content); - const expectedNames = marketplace.plugins.map( + const content = await readFile(PROJECTS_JSON, "utf-8"); + const projects = JSON.parse(content); + const expectedNames = projects.projects.map( (p: { name: string }) => p.name ); expect(names.sort()).toEqual(expectedNames.sort()); }); - test("get_plugin_source returns correct repo for each plugin", async () => { - const content = await readFile(MARKETPLACE_JSON, "utf-8"); - const marketplace = JSON.parse(content); - - for (const plugin of marketplace.plugins) { - const result = await callFunction("get_plugin_source", plugin.name); - expect(result).toBe(plugin.source.repo); - } - }); - - test("get_plugin_description returns correct description for each plugin", async () => { - const content = await readFile(MARKETPLACE_JSON, "utf-8"); - const marketplace = JSON.parse(content); + test("get_project_repo returns correct repo for each project", async () => { + const content = await readFile(PROJECTS_JSON, "utf-8"); + const projects = JSON.parse(content); - for (const plugin of marketplace.plugins) { - const result = await callFunction("get_plugin_description", plugin.name); - expect(result).toBe(plugin.description); + for (const project of projects.projects) { + const result = await callFunction("get_project_repo", project.name); + expect(result).toBe(project.repo); } }); }); @@ -351,7 +459,7 @@ describe("install.sh - CLI", () => { const output = result.text(); expect(output).toContain("Usage:"); expect(output).toContain("--agent"); - expect(output).toContain("--plugin"); + expect(output).toContain("--project"); expect(output).toContain("--list"); }); @@ -361,17 +469,17 @@ describe("install.sh - CLI", () => { expect(result.text()).toContain("Usage:"); }); - test("--list shows available plugins", async () => { + test("--list shows available projects", async () => { const result = await $`bash ${INSTALL_SCRIPT} --list`.quiet(); expect(result.exitCode).toBe(0); const output = result.text(); - expect(output).toContain("Available Plugins"); + expect(output).toContain("Available Projects"); - // Check that all plugins from marketplace.json are listed - const content = await readFile(MARKETPLACE_JSON, "utf-8"); - const marketplace = JSON.parse(content); - for (const plugin of marketplace.plugins) { - expect(output).toContain(plugin.name); + // Check that all projects from projects.json are listed + const content = await readFile(PROJECTS_JSON, "utf-8"); + const projects = JSON.parse(content); + for (const project of projects.projects) { + expect(output).toContain(project.name); } }); @@ -400,31 +508,33 @@ describe("install.sh - CLI", () => { describe("README.md consistency", () => { let readme: string; - let marketplace: { - plugins: Array<{ name: string; description: string }>; + let projects: { + projects: Array<{ name: string; repo: string }>; }; beforeAll(async () => { readme = await readFile(README_PATH, "utf-8"); - const content = await readFile(MARKETPLACE_JSON, "utf-8"); - marketplace = JSON.parse(content); + const content = await readFile(PROJECTS_JSON, "utf-8"); + projects = JSON.parse(content); }); - test("lists all plugins from marketplace.json", () => { - for (const plugin of marketplace.plugins) { - expect(readme).toContain(plugin.name); + test("lists all projects from projects.json", () => { + for (const project of projects.projects) { + expect(readme).toContain(project.name); } }); test("has correct agent directory mappings", () => { const mappings = [ - ["gemini", ".gemini/skills/"], - ["copilot", ".github/skills/"], - ["cursor", ".cursor/skills/"], - ["opencode", ".opencode/skill/"], - ["amp", ".amp/skills/"], - ["goose", ".goose/skills/"], - ["factory", ".factory/skills/"], + ["gemini", ".gemini/skills"], + ["copilot", ".github/skills"], + ["cursor", ".cursor/skills"], + ["opencode", ".opencode/skill"], + ["amp", ".amp/skills"], + ["goose", ".goose/skills"], + ["factory", ".factory/skills"], + ["codex", ".codex/skills"], + ["windsurf", ".windsurf/skills"], ]; for (const [agent, dir] of mappings) { @@ -435,7 +545,428 @@ describe("README.md consistency", () => { test("curl command uses correct URL", () => { expect(readme).toContain( - "https://raw.githubusercontent.com/plaited/marketplace/main/install.sh" + "https://raw.githubusercontent.com/plaited/skills-installer/main/install.sh" ); }); }); + +describe("install.sh - convert_md_to_toml", () => { + const tmpDir = join(import.meta.dir, ".test-tmp"); + + // Helper to run convert_md_to_toml + async function convertMdToToml( + mdContent: string + ): Promise { + const { mkdir, writeFile, readFile, rm } = await import("fs/promises"); + await mkdir(tmpDir, { recursive: true }); + + const mdPath = join(tmpDir, "test-command.md"); + const tomlPath = join(tmpDir, "test-command.toml"); + const scriptPath = join(tmpDir, "run-convert.sh"); + + await writeFile(mdPath, mdContent); + + // Write script to temp file to avoid escaping issues + const script = `#!/bin/bash +convert_md_to_toml() { + local md_file="$1" + local toml_file="$2" + + local description + description=$(awk ' + /^---$/ { if (in_front) exit; in_front=1; next } + in_front && /^description:/ { + sub(/^description:[[:space:]]*/, "") + gsub(/"/, "\\\\\\"") + print + exit + } + ' "$md_file") + + local body + body=$(awk ' + /^---$/ { count++; if (count == 2) { getbody=1; next } } + getbody { print } + ' "$md_file") + + body=$(printf '%s\\n' "$body" | sed 's/\\$ARGUMENTS/{{args}}/g') + + { + if [ -n "$description" ]; then + echo "description = \\"$description\\"" + echo "" + fi + echo 'prompt = """' + echo "$body" + echo '"""' + } > "$toml_file" +} + +convert_md_to_toml "${mdPath}" "${tomlPath}" +`; + + await writeFile(scriptPath, script); + + await $`bash ${scriptPath}`.quiet(); + + const result = await readFile(tomlPath, "utf-8"); + await rm(tmpDir, { recursive: true, force: true }); + return result; + } + + test("converts basic markdown with description", async () => { + const md = `--- +description: Test description +allowed-tools: Bash +--- + +# Test Command + +This is the body. +`; + + const toml = await convertMdToToml(md); + expect(toml).toContain('description = "Test description"'); + expect(toml).toContain('prompt = """'); + expect(toml).toContain("# Test Command"); + expect(toml).toContain("This is the body."); + expect(toml).toContain('"""'); + // allowed-tools should be dropped (not in output) + expect(toml).not.toContain("allowed-tools"); + expect(toml).not.toContain("Bash"); + }); + + test("replaces $ARGUMENTS with {{args}}", async () => { + const md = `--- +description: Command with args +--- + +Use $ARGUMENTS here and $ARGUMENTS again. +`; + + const toml = await convertMdToToml(md); + expect(toml).toContain("{{args}}"); + expect(toml).not.toContain("$ARGUMENTS"); + // Should have two replacements + const matches = toml.match(/\{\{args\}\}/g); + expect(matches?.length).toBe(2); + }); + + test("handles markdown without description", async () => { + const md = `--- +allowed-tools: Bash +--- + +# No Description + +Just a body. +`; + + const toml = await convertMdToToml(md); + expect(toml).not.toContain("description ="); + expect(toml).toContain('prompt = """'); + expect(toml).toContain("# No Description"); + }); + + test("escapes quotes in description", async () => { + const md = `--- +description: A "quoted" description +--- + +Body text. +`; + + const toml = await convertMdToToml(md); + expect(toml).toContain('description = "A \\"quoted\\" description"'); + }); +}); + +describe("install.sh - supports_commands updated", () => { + test("gemini now supports commands", async () => { + const exitCode = await callFunctionExitCode("supports_commands", "gemini"); + expect(exitCode).toBe(0); + }); +}); + +describe("install.sh - convert_md_to_codex_prompt", () => { + const tmpDir = join(import.meta.dir, ".test-tmp-codex"); + + // Helper to run convert_md_to_codex_prompt + async function convertMdToCodexPrompt(mdContent: string): Promise { + const { mkdir, writeFile, readFile, rm } = await import("fs/promises"); + await mkdir(tmpDir, { recursive: true }); + + const mdPath = join(tmpDir, "test-command.md"); + const promptPath = join(tmpDir, "test-command-prompt.md"); + const scriptPath = join(tmpDir, "run-test.sh"); + + await writeFile(mdPath, mdContent); + + // Create wrapper script that extracts and runs the function + const script = `#!/bin/bash +set -e +# Define print_error stub and safe_read_file helper +print_error() { echo "✗ $1" >&2; } +MAX_FILE_SIZE=102400 +safe_read_file() { + local file="$1" + local max_size="\${2:-$MAX_FILE_SIZE}" + if [ ! -f "$file" ]; then return 1; fi + local file_size; file_size=$(wc -c < "$file") + if [ "$file_size" -gt "$max_size" ]; then print_error "File exceeds size limit"; return 1; fi + cat "$file" +} + +# Define shared frontmatter helpers +has_frontmatter() { + local file="$1" + if [ ! -f "$file" ]; then return 1; fi + head -1 "$file" 2>/dev/null | grep -q '^---$' +} + +extract_frontmatter_field() { + local file="$1" + local field="$2" + local strip_quotes="\${3:-true}" + if [ ! -f "$file" ]; then return 1; fi + awk -v field="$field" -v strip="$strip_quotes" ' + /^---$/ { if (in_front) exit; in_front=1; next } + in_front && $0 ~ "^" field ":" { + sub("^" field ":[[:space:]]*", "") + if (strip == "true") { gsub(/^["'"'"']|["'"'"']$/, "") } + print + exit + } + ' "$file" +} + +extract_body() { + local file="$1" + if [ ! -f "$file" ]; then return 1; fi + awk ' + /^---$/ { count++; if (count == 2) { getbody=1; next } } + getbody { print } + ' "$file" +} + +# Extract convert_md_to_codex_prompt function from install.sh +eval "$(sed -n '/^convert_md_to_codex_prompt()/,/^}/p' '${INSTALL_SCRIPT}')" +convert_md_to_codex_prompt '${mdPath}' '${promptPath}' +`; + await writeFile(scriptPath, script); + await $`bash ${scriptPath}`.quiet(); + + const output = await readFile(promptPath, "utf-8"); + await rm(tmpDir, { recursive: true, force: true }); + return output; + } + + test("converts markdown with frontmatter to codex prompt", async () => { + const md = `--- +description: Review code for issues +--- + +Review the provided code for bugs and security issues. +`; + + const prompt = await convertMdToCodexPrompt(md); + expect(prompt).toContain("---"); + expect(prompt).toContain("description: Review code for issues"); + expect(prompt).toContain("Review the provided code"); + }); + + test("extracts description from body when not in frontmatter", async () => { + const md = `--- +allowed-tools: Bash +--- + +# Code Review Helper + +Review the code. +`; + + const prompt = await convertMdToCodexPrompt(md); + expect(prompt).toContain("description:"); + expect(prompt).toContain("Code Review Helper"); + }); + + test("detects placeholders and creates argument-hint", async () => { + const md = `--- +description: Process files +--- + +Process $FILE with options $OPTIONS. +`; + + const prompt = await convertMdToCodexPrompt(md); + expect(prompt).toContain("argument-hint:"); + expect(prompt).toMatch(/FILE=/); + expect(prompt).toMatch(/OPTIONS=/); + }); + + test("handles plain markdown without frontmatter", async () => { + const md = `# Simple Command + +Just do the thing. +`; + + const prompt = await convertMdToCodexPrompt(md); + expect(prompt).toContain("---"); + expect(prompt).toContain("description:"); + expect(prompt).toContain("Just do the thing"); + }); +}); + +describe("install.sh - convert_md_to_windsurf_workflow", () => { + const tmpDir = join(import.meta.dir, ".test-tmp-windsurf"); + + // Helper to run convert_md_to_windsurf_workflow + async function convertMdToWindsurfWorkflow(mdContent: string): Promise { + const { mkdir, writeFile, readFile, rm } = await import("fs/promises"); + await mkdir(tmpDir, { recursive: true }); + + const mdPath = join(tmpDir, "test-command.md"); + const workflowPath = join(tmpDir, "test-workflow.md"); + const scriptPath = join(tmpDir, "run-test.sh"); + + await writeFile(mdPath, mdContent); + + // Create wrapper script that extracts and runs the function + const script = `#!/bin/bash +set -e +# Define print_info, print_error stubs and safe_read_file helper +print_info() { echo "→ $1"; } +print_error() { echo "✗ $1" >&2; } +MAX_FILE_SIZE=102400 +safe_read_file() { + local file="$1" + local max_size="\${2:-$MAX_FILE_SIZE}" + if [ ! -f "$file" ]; then return 1; fi + local file_size; file_size=$(wc -c < "$file") + if [ "$file_size" -gt "$max_size" ]; then print_error "File exceeds size limit"; return 1; fi + cat "$file" +} + +# Define shared frontmatter helpers +has_frontmatter() { + local file="$1" + if [ ! -f "$file" ]; then return 1; fi + head -1 "$file" 2>/dev/null | grep -q '^---$' +} + +extract_frontmatter_field() { + local file="$1" + local field="$2" + local strip_quotes="\${3:-true}" + if [ ! -f "$file" ]; then return 1; fi + awk -v field="$field" -v strip="$strip_quotes" ' + /^---$/ { if (in_front) exit; in_front=1; next } + in_front && $0 ~ "^" field ":" { + sub("^" field ":[[:space:]]*", "") + if (strip == "true") { gsub(/^["'"'"']|["'"'"']$/, "") } + print + exit + } + ' "$file" +} + +extract_body() { + local file="$1" + if [ ! -f "$file" ]; then return 1; fi + awk ' + /^---$/ { count++; if (count == 2) { getbody=1; next } } + getbody { print } + ' "$file" +} + +# Extract convert_md_to_windsurf_workflow function from install.sh +eval "$(sed -n '/^convert_md_to_windsurf_workflow()/,/^}/p' '${INSTALL_SCRIPT}')" +convert_md_to_windsurf_workflow '${mdPath}' '${workflowPath}' +`; + await writeFile(scriptPath, script); + await $`bash ${scriptPath}`.quiet(); + + const result = await readFile(workflowPath, "utf-8"); + await rm(tmpDir, { recursive: true, force: true }); + return result; + } + + test("converts markdown with frontmatter to workflow", async () => { + const md = `--- +name: Code Review +description: Review code for issues +--- + +1. Check for bugs +2. Check for security issues +3. Report findings +`; + + const workflow = await convertMdToWindsurfWorkflow(md); + expect(workflow).toContain("# Code Review"); + expect(workflow).toContain("Review code for issues"); + expect(workflow).toContain("1. Check for bugs"); + }); + + test("extracts name from heading when not in frontmatter", async () => { + const md = `--- +description: A helpful workflow +--- + +# My Custom Workflow + +Do the thing. +`; + + const workflow = await convertMdToWindsurfWorkflow(md); + expect(workflow).toContain("# My Custom Workflow"); + expect(workflow).toContain("A helpful workflow"); + }); + + test("wraps content in Instructions section when no numbered steps", async () => { + const md = `--- +name: Simple Task +description: Do something simple +--- + +Just follow these instructions to complete the task. +Make sure to be careful. +`; + + const workflow = await convertMdToWindsurfWorkflow(md); + expect(workflow).toContain("# Simple Task"); + expect(workflow).toContain("## Instructions"); + expect(workflow).toContain("Just follow these instructions"); + }); + + test("preserves numbered steps without wrapping", async () => { + const md = `--- +name: Numbered Steps +description: Steps workflow +--- + +1. First step +2. Second step +3. Third step +`; + + const workflow = await convertMdToWindsurfWorkflow(md); + expect(workflow).toContain("1. First step"); + expect(workflow).not.toContain("## Instructions"); + }); + + test("handles plain markdown without frontmatter", async () => { + const md = `# Deploy Script + +Deploy the application to staging. + +1. Build the app +2. Run tests +3. Deploy to staging +`; + + const workflow = await convertMdToWindsurfWorkflow(md); + expect(workflow).toContain("# Deploy Script"); + expect(workflow).toContain("1. Build the app"); + }); +}); diff --git a/package.json b/package.json index 8738cf5..e283b1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "plaited-marketplace", "private": "true", - "description": "Aggregator for Plaited's Claude Code plugins", + "name": "@plaited/skills-installer", + "description": "Install Plaited skills for agent-skills-spec compatible AI coding agents", "type": "module", "scripts": { "test": "bun test", diff --git a/projects.json b/projects.json new file mode 100644 index 0000000..410f5cc --- /dev/null +++ b/projects.json @@ -0,0 +1,16 @@ +{ + "projects": [ + { + "name": "development-skills", + "repo": "plaited/development-skills" + }, + { + "name": "acp-harness", + "repo": "plaited/acp-harness" + }, + { + "name": "plaited", + "repo": "plaited/plaited" + } + ] +} diff --git a/projects.json.sha256 b/projects.json.sha256 new file mode 100644 index 0000000..d73eebd --- /dev/null +++ b/projects.json.sha256 @@ -0,0 +1 @@ +c419013bdb86dc8de7c4f974fdb13545966c5beadebd20f3e61275a75b43101b projects.json