agent-skill-creator/scripts/install-skill.sh
Francy Lisboa Charuto 3fc735eeae feat: Add brain-dead multi-tool install scripts (bootstrap, symlink installer, universal skill installer)
- scripts/bootstrap.sh: curl-pipe-sh one-liner that clones to ~/.agents/skills/ and symlinks to all detected global platforms
- install.sh: symlink self-installer for cloned repos with --dry-run and --uninstall
- scripts/install-skill.sh: universal installer for any skill (git URL or local path) to all detected platforms with format adapters
- SKILL.md: add silent git-based update check instruction
- README.md: document all new install options and update project structure
- .gitignore: add *.mdc for generated adapter files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:08:20 -03:00

540 lines
17 KiB
Bash
Executable file

#!/bin/sh
# install-skill.sh — Install any skill (git URL or local path) to all detected platforms
#
# Usage:
# ./scripts/install-skill.sh https://github.com/someone/sales-report-skill.git
# ./scripts/install-skill.sh ./sales-report-skill
# ./scripts/install-skill.sh ./sales-report-skill --platform cursor --project
# ./scripts/install-skill.sh ./sales-report-skill --dry-run
# ./scripts/install-skill.sh ./sales-report-skill --uninstall
#
# POSIX-compatible (works in bash, dash, zsh, ash).
set -eu
# ---------------------------------------------------------------------------
# Colors (disabled when stdout is not a terminal)
# ---------------------------------------------------------------------------
if [ -t 1 ]; then
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m'
else
GREEN='' YELLOW='' BLUE='' RED='' BOLD='' NC=''
fi
info() { printf "${BLUE}[INFO]${NC} %s\n" "$1"; }
success() { printf "${GREEN}[OK]${NC} %s\n" "$1"; }
warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1"; }
error() { printf "${RED}[ERROR]${NC} %s\n" "$1" >&2; }
# ---------------------------------------------------------------------------
# Options
# ---------------------------------------------------------------------------
SOURCE=""
PLATFORM=""
PROJECT_LEVEL=false
DRY_RUN=false
UNINSTALL=false
usage() {
cat <<'USAGE'
Usage: install-skill.sh <source> [options]
Arguments:
<source> Git URL (https://... or *.git) or local directory path
Options:
--platform <name> Install to a specific platform only
--project Use project-level paths (for Cursor, Windsurf, etc.)
--all Install to all detected platforms (default)
--dry-run Preview without making changes
--uninstall Remove the skill from all platforms
-h, --help Show this help message
Examples:
install-skill.sh https://github.com/someone/sales-report-skill.git
install-skill.sh ./sales-report-skill
install-skill.sh ./sales-report-skill --platform cursor --project
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--platform)
shift
PLATFORM="${1:-}"
if [ -z "$PLATFORM" ]; then
error "--platform requires a value"
exit 1
fi
;;
--project) PROJECT_LEVEL=true ;;
--all) PLATFORM="" ;;
--dry-run) DRY_RUN=true ;;
--uninstall) UNINSTALL=true ;;
-h|--help) usage; exit 0 ;;
-*) error "Unknown option: $1"; exit 1 ;;
*)
if [ -z "$SOURCE" ]; then
SOURCE="$1"
else
error "Unexpected argument: $1"
exit 1
fi
;;
esac
shift
done
if [ -z "$SOURCE" ]; then
error "Missing required argument: <source>"
usage
exit 1
fi
# ---------------------------------------------------------------------------
# Resolve source: git clone or validate local path
# ---------------------------------------------------------------------------
is_git_url() {
case "$1" in
*://*) return 0 ;;
*.git) return 0 ;;
esac
return 1
}
resolve_source() {
if is_git_url "$SOURCE"; then
# Extract skill name from URL
skill_basename="$(basename "$SOURCE" .git)"
canonical_dir="$HOME/.agents/skills/$skill_basename"
if [ -d "$canonical_dir/.git" ]; then
info "Updating existing install at $canonical_dir"
if [ "$DRY_RUN" = false ]; then
cd "$canonical_dir" && git pull --ff-only 2>/dev/null || true
cd - >/dev/null
fi
else
info "Cloning $SOURCE"
if [ "$DRY_RUN" = false ]; then
mkdir -p "$(dirname "$canonical_dir")"
rm -rf "$canonical_dir"
git clone "$SOURCE" "$canonical_dir"
fi
fi
SOURCE_DIR="$canonical_dir"
else
# Local path
if [ ! -d "$SOURCE" ]; then
error "Source directory not found: $SOURCE"
exit 1
fi
SOURCE_DIR="$(cd "$SOURCE" && pwd)"
fi
}
# ---------------------------------------------------------------------------
# Extract skill name from directory or SKILL.md frontmatter
# ---------------------------------------------------------------------------
extract_skill_name() {
skill_name=""
skill_md="$SOURCE_DIR/SKILL.md"
# Try to extract from SKILL.md frontmatter
if [ -f "$skill_md" ]; then
in_fm=false
lnum=0
while IFS= read -r line; do
lnum=$((lnum + 1))
if [ "$lnum" -eq 1 ] && [ "$line" = "---" ]; then
in_fm=true
continue
fi
if $in_fm && [ "$line" = "---" ]; then break; fi
if $in_fm; then
case "$line" in
name:*)
skill_name="$(echo "$line" | sed 's/^name:[[:space:]]*//' | sed 's/^["'"'"']//' | sed 's/["'"'"']$//')"
;;
esac
fi
done < "$skill_md"
fi
# Fallback to directory basename
if [ -z "$skill_name" ]; then
skill_name="$(basename "$SOURCE_DIR")"
fi
SKILL_NAME="$skill_name"
}
# ---------------------------------------------------------------------------
# Validate SKILL.md exists
# ---------------------------------------------------------------------------
validate_source() {
if [ ! -f "$SOURCE_DIR/SKILL.md" ]; then
error "No SKILL.md found in $SOURCE_DIR"
error "A valid skill must contain a SKILL.md file."
exit 1
fi
}
# ---------------------------------------------------------------------------
# Platform detection and path resolution
# ---------------------------------------------------------------------------
detect_all_global_platforms() {
platforms=""
if [ -d "$HOME/.claude" ]; then platforms="$platforms claude-code"; fi
if [ -d "$HOME/.gemini" ]; then platforms="$platforms gemini"; fi
if [ -d "$HOME/.config/goose" ]; then platforms="$platforms goose"; fi
if [ -d "$HOME/.config/opencode" ]; then platforms="$platforms opencode"; fi
if [ -d "$HOME/.copilot" ]; then platforms="$platforms copilot"; fi
echo "$platforms"
}
detect_all_project_platforms() {
platforms=""
if [ -d ".cursor" ]; then platforms="$platforms cursor"; fi
if [ -d ".windsurf" ]; then platforms="$platforms windsurf"; fi
if [ -d ".clinerules" ] || [ -d ".cline" ]; then platforms="$platforms cline"; fi
if [ -d ".kiro" ]; then platforms="$platforms kiro"; fi
if [ -d ".trae" ]; then platforms="$platforms trae"; fi
if [ -d ".roo" ]; then platforms="$platforms roo-code"; fi
if [ -d ".github" ]; then platforms="$platforms copilot"; fi
echo "$platforms"
}
resolve_platform_path() {
plat="$1"
name="$2"
if [ "$PROJECT_LEVEL" = true ]; then
case "$plat" in
claude-code) echo ".claude/skills/$name" ;;
copilot) echo ".github/skills/$name" ;;
cursor) echo ".cursor/rules/$name" ;;
windsurf) echo ".windsurf/rules/$name" ;;
cline) echo ".clinerules/$name" ;;
gemini) echo ".gemini/skills/$name" ;;
kiro) echo ".kiro/skills/$name" ;;
trae) echo ".trae/rules/$name" ;;
roo-code) echo ".roo/rules/$name" ;;
goose) echo ".agents/skills/$name" ;;
opencode) echo ".agents/skills/$name" ;;
*) echo ".agents/skills/$name" ;;
esac
else
case "$plat" in
claude-code) echo "$HOME/.claude/skills/$name" ;;
copilot) echo "$HOME/.copilot/skills/$name" ;;
cursor) echo "$HOME/.cursor/rules/$name" ;;
windsurf) echo "$HOME/.codeium/windsurf/skills/$name" ;;
cline) echo "$HOME/.cline/rules/$name" ;;
gemini) echo "$HOME/.gemini/skills/$name" ;;
goose) echo "$HOME/.config/goose/skills/$name" ;;
opencode) echo "$HOME/.config/opencode/skills/$name" ;;
kiro) echo "$HOME/.agents/skills/$name" ;;
trae) echo "$HOME/.agents/skills/$name" ;;
roo-code) echo "$HOME/.agents/skills/$name" ;;
*) echo "$HOME/.agents/skills/$name" ;;
esac
fi
}
platform_display() {
case "$1" in
claude-code) echo "Claude Code" ;;
gemini) echo "Gemini CLI" ;;
goose) echo "Goose" ;;
opencode) echo "OpenCode" ;;
copilot) echo "GitHub Copilot" ;;
cursor) echo "Cursor" ;;
windsurf) echo "Windsurf" ;;
cline) echo "Cline" ;;
kiro) echo "Kiro" ;;
trae) echo "Trae" ;;
roo-code) echo "Roo Code" ;;
*) echo "$1" ;;
esac
}
# ---------------------------------------------------------------------------
# Format adapters (for Tier 2 platforms)
# ---------------------------------------------------------------------------
generate_cursor_mdc() {
target_dir="$1"
skill_md="$SOURCE_DIR/SKILL.md"
desc=""
in_fm=false
lnum=0
while IFS= read -r line; do
lnum=$((lnum + 1))
if [ "$lnum" -eq 1 ]; then in_fm=true; continue; fi
if $in_fm && [ "$line" = "---" ]; then break; fi
if $in_fm; then
case "$line" in
description:*) desc="$(echo "$line" | sed 's/^description:[[:space:]]*//')" ;;
esac
fi
done < "$skill_md"
mdc_file="${target_dir}/${SKILL_NAME}.mdc"
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would generate Cursor .mdc: $mdc_file"
return 0
fi
body="$(awk 'BEGIN{c=0} /^---$/{c++;next} c>=2{print}' "$skill_md")"
mkdir -p "$target_dir"
cat > "$mdc_file" <<MDCEOF
---
description: ${desc}
globs:
alwaysApply: true
---
${body}
MDCEOF
success "Generated Cursor .mdc: $mdc_file"
}
generate_windsurf_rule() {
target_dir="$1"
is_global="$2"
skill_md="$SOURCE_DIR/SKILL.md"
body="$(awk 'BEGIN{c=0} /^---$/{c++;next} c>=2{print}' "$skill_md")"
if [ "$is_global" = "true" ]; then
global_file="$HOME/.codeium/windsurf/memories/global_rules.md"
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would append to Windsurf global_rules.md: $global_file"
return 0
fi
mkdir -p "$(dirname "$global_file")"
if [ -f "$global_file" ]; then
awk -v begin_marker="<!-- BEGIN ${SKILL_NAME} -->" \
-v end_marker="<!-- END ${SKILL_NAME} -->" '
BEGIN { skip=0 }
$0 == begin_marker { skip=1; next }
$0 == end_marker { skip=0; next }
!skip { print }
' "$global_file" > "${global_file}.tmp"
mv "${global_file}.tmp" "$global_file"
fi
cat >> "$global_file" <<WSEOF
<!-- BEGIN ${SKILL_NAME} -->
${body}
<!-- END ${SKILL_NAME} -->
WSEOF
success "Appended to Windsurf global_rules.md"
else
rule_file="${target_dir}/${SKILL_NAME}.md"
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would generate Windsurf rule: $rule_file"
return 0
fi
mkdir -p "$target_dir"
printf '%s\n' "$body" > "$rule_file"
success "Generated Windsurf rule: $rule_file"
fi
}
generate_plain_rule() {
target_dir="$1"
filename="$2"
skill_md="$SOURCE_DIR/SKILL.md"
plain_file="${target_dir}/${filename}"
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would generate plain rule: $plain_file"
return 0
fi
mkdir -p "$target_dir"
awk 'BEGIN{c=0} /^---$/{c++;next} c>=2{print}' "$skill_md" > "$plain_file"
success "Generated plain rule: $plain_file"
}
run_adapters() {
plat="$1"
dest="$2"
case "$plat" in
cursor)
generate_cursor_mdc "$dest"
;;
windsurf)
if [ "$PROJECT_LEVEL" = true ]; then
generate_windsurf_rule "$(pwd)/.windsurf/rules" "false"
else
generate_windsurf_rule "" "true"
fi
;;
cline|roo-code|trae)
generate_plain_rule "$dest" "${SKILL_NAME}.md"
;;
esac
}
# ---------------------------------------------------------------------------
# Create a symlink (with fallback to copy)
# ---------------------------------------------------------------------------
create_symlink() {
target="$1"
link_path="$2"
if [ "$target" = "$link_path" ]; then return 0; fi
mkdir -p "$(dirname "$link_path")"
if [ -e "$link_path" ] || [ -L "$link_path" ]; then
rm -rf "$link_path"
fi
if ln -s "$target" "$link_path" 2>/dev/null; then
return 0
else
warn "Symlink failed for $link_path — falling back to copy"
cp -R "$target" "$link_path"
fi
}
# ---------------------------------------------------------------------------
# Install to a single platform
# ---------------------------------------------------------------------------
install_to_platform() {
plat="$1"
dest="$(resolve_platform_path "$plat" "$SKILL_NAME")"
display="$(platform_display "$plat")"
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would install to $display: $dest"
run_adapters "$plat" "$dest"
return 0
fi
create_symlink "$SOURCE_DIR" "$dest"
success "Installed for $display$dest"
run_adapters "$plat" "$dest"
}
# ---------------------------------------------------------------------------
# Uninstall from all platforms
# ---------------------------------------------------------------------------
do_uninstall() {
printf "\n${BOLD}Uninstalling skill: %s${NC}\n\n" "$SKILL_NAME"
# Canonical location
canonical="$HOME/.agents/skills/$SKILL_NAME"
if [ -e "$canonical" ] || [ -L "$canonical" ]; then
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would remove: $canonical"
else
rm -rf "$canonical"
success "Removed: $canonical"
fi
fi
# Check all global platforms
for plat in claude-code gemini goose opencode copilot; do
dest="$(resolve_platform_path "$plat" "$SKILL_NAME")"
if [ -e "$dest" ] || [ -L "$dest" ]; then
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would remove: $dest"
else
rm -rf "$dest"
success "Removed: $dest ($(platform_display "$plat"))"
fi
fi
done
# Check project-level platforms
for plat in cursor windsurf cline kiro trae roo-code copilot; do
PROJECT_LEVEL=true
dest="$(resolve_platform_path "$plat" "$SKILL_NAME")"
if [ -e "$dest" ] || [ -L "$dest" ]; then
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would remove: $dest"
else
rm -rf "$dest"
success "Removed: $dest ($(platform_display "$plat"))"
fi
fi
done
printf "\nDone.\n"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
printf "\n${BOLD}Universal Skill Installer${NC}\n\n"
resolve_source
if [ "$DRY_RUN" = false ]; then
validate_source
elif [ -d "$SOURCE_DIR" ]; then
validate_source
fi
extract_skill_name
info "Skill: $SKILL_NAME"
info "Source: $SOURCE_DIR"
if [ "$UNINSTALL" = true ]; then
do_uninstall
return 0
fi
# Install to canonical location first (if from git, already there)
canonical="$HOME/.agents/skills/$SKILL_NAME"
if ! is_git_url "$SOURCE" && [ "$SOURCE_DIR" != "$canonical" ]; then
if [ "$DRY_RUN" = true ]; then
info "[dry-run] Would copy to canonical: $canonical"
else
mkdir -p "$(dirname "$canonical")"
rm -rf "$canonical"
cp -R "$SOURCE_DIR" "$canonical"
success "Copied to canonical: $canonical"
fi
fi
# Determine which platforms to install to
if [ -n "$PLATFORM" ]; then
# Single platform
install_to_platform "$PLATFORM"
else
# All detected platforms
if [ "$PROJECT_LEVEL" = true ]; then
platforms="$(detect_all_project_platforms)"
else
platforms="$(detect_all_global_platforms)"
fi
count=0
for plat in $platforms; do
install_to_platform "$plat"
count=$((count + 1))
done
if [ $count -eq 0 ]; then
warn "No platforms detected. Skill installed at canonical path only."
fi
fi
# Summary
printf "\n${BOLD}Done!${NC}\n"
printf " Canonical: %s\n" "$canonical"
printf " Invoke with: /${SKILL_NAME}\n\n"
if [ "$DRY_RUN" = true ]; then
printf "${YELLOW}Dry run — no changes made.${NC}\n\n"
fi
}
main