Files
2026-03-22 23:21:49 +02:00

528 lines
14 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
#
# Skill Migrator - Migrate LLM tools to OpenCode skills directory
#
set -euo pipefail
# Configuration
GLOBAL_SKILLS_DIR="${HOME}/.config/opencode/skills"
LOCAL_SKILLS_DIR=".opencode/skills"
TARGET_MODE="global"
DRY_RUN=false
# Non-interactive mode flags
MIGRATE_ALL=false
AUTO_CONFIRM=false
CONFLICT_STRATEGY=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Data structures
declare -a DISCOVERED_SKILLS=()
declare -a DISCOVERED_PATHS=()
declare -a MIGRATED_SKILLS=()
declare -a SKIPPED_SKILLS=()
declare -a BACKED_UP_SKILLS=()
declare -a ERROR_SKILLS=()
# Show help
show_help() {
cat << EOF
Skill Migrator - Migrate LLM tools to OpenCode
Usage: $(basename "$0") [OPTIONS] <source-directory>
Migrate skills from a source directory to OpenCode's skills directory.
Options:
-a, --all Migrate all discovered skills without prompting
-c, --conflict-strategy Set conflict resolution strategy: skip, overwrite, backup
-y, --yes Auto-confirm all prompts without interaction
-l, --local Install to local project (.opencode/skills/)
-g, --global Install to global directory (default: ~/.config/opencode/skills/)
-d, --dry-run Preview changes without migrating
-h, --help Show this help message
Interactive Options:
By default, the script will prompt you to:
- Select which skills to migrate
- Resolve conflicts (overwrite/skip/backup)
- Confirm before proceeding
Use -a, -y, and -c flags for non-interactive/automated usage.
Examples:
# Interactive mode (default)
$(basename "$0") ~/my-skills
# Non-interactive: migrate all skills
$(basename "$0") ~/my-skills --all --yes
# Non-interactive: migrate all, skip existing
$(basename "$0") ~/my-skills --all --yes --conflict-strategy skip
# Non-interactive: migrate all, overwrite existing
$(basename "$0") ~/my-skills --all --yes --conflict-strategy overwrite
# Dry run (preview only)
$(basename "$0") ~/my-skills --all --dry-run
# Install to local project
$(basename "$0") ~/my-skills --local --all --yes
EOF
}
# Print colored output
print_error() {
echo -e "${RED}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_warning() {
echo -e "${YELLOW}${NC} $1"
}
print_info() {
echo -e "${BLUE}${NC} $1"
}
# Parse command line arguments
parse_args() {
SOURCE_DIR=""
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-a|--all)
MIGRATE_ALL=true
shift
;;
-y|--yes)
AUTO_CONFIRM=true
shift
;;
-c|--conflict-strategy)
if [[ -n "${2:-}" && ! "$2" =~ ^- ]]; then
CONFLICT_STRATEGY="$2"
# Validate conflict strategy
if [[ "$CONFLICT_STRATEGY" != "skip" && "$CONFLICT_STRATEGY" != "overwrite" && "$CONFLICT_STRATEGY" != "backup" ]]; then
print_error "Invalid conflict strategy: $CONFLICT_STRATEGY"
print_error "Valid options: skip, overwrite, backup"
exit 1
fi
shift 2
else
print_error "--conflict-strategy requires a value (skip, overwrite, backup)"
exit 1
fi
;;
-l|--local)
TARGET_MODE="local"
shift
;;
-g|--global)
TARGET_MODE="global"
shift
;;
-d|--dry-run)
DRY_RUN=true
shift
;;
-*)
print_error "Unknown option: $1"
show_help
exit 1
;;
*)
if [[ -z "$SOURCE_DIR" ]]; then
SOURCE_DIR="$1"
else
print_error "Multiple source directories specified"
exit 1
fi
shift
;;
esac
done
if [[ -z "$SOURCE_DIR" ]]; then
print_error "No source directory specified"
show_help
exit 1
fi
}
# Get target directory
get_target_dir() {
if [[ "$TARGET_MODE" == "local" ]]; then
if [[ ! -d "$LOCAL_SKILLS_DIR" ]]; then
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$LOCAL_SKILLS_DIR"
fi
fi
echo "$LOCAL_SKILLS_DIR"
else
if [[ ! -d "$GLOBAL_SKILLS_DIR" ]]; then
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$GLOBAL_SKILLS_DIR"
fi
fi
echo "$GLOBAL_SKILLS_DIR"
fi
}
# Discover skills recursively
discover_skills() {
local source_dir="$1"
print_info "Discovering skills in: $source_dir"
# Find all directories containing SKILL.md
local found_skills=0
while IFS= read -r -d '' skill_path; do
local skill_dir
skill_dir=$(dirname "$skill_path")
local skill_name
skill_name=$(basename "$skill_dir")
# Skip the skill-migrator itself
if [[ "$skill_name" == "skill-migrator" ]]; then
continue
fi
DISCOVERED_SKILLS+=("$skill_name")
DISCOVERED_PATHS+=("$skill_dir")
((found_skills++)) || true
done < <(find "$source_dir" -type f -name "SKILL.md" -print0 2>/dev/null)
if [[ ${#DISCOVERED_SKILLS[@]} -eq 0 ]]; then
print_warning "No skills found in: $source_dir"
print_info "Looking for directories containing 'SKILL.md'"
exit 0
fi
print_success "Found $found_skills skill(s)"
}
# Display discovered skills
display_discovered() {
echo ""
echo "Discovered Skills:"
echo "=================="
local i=1
for skill_name in "${DISCOVERED_SKILLS[@]}"; do
printf "%2d. %-30s (%s)\n" "$i" "$skill_name" "${DISCOVERED_PATHS[$((i-1))]}"
((i++)) || true
done
echo ""
}
# Prompt user for skill selection
select_skills() {
# If --all flag is set, select all skills automatically
if [[ "$MIGRATE_ALL" == true ]]; then
SELECTED_INDICES=($(seq 1 ${#DISCOVERED_SKILLS[@]}))
print_info "Auto-selecting all ${#DISCOVERED_SKILLS[@]} skill(s) (--all flag set)"
return
fi
echo "Select skills to migrate:"
echo " [a] - Migrate all"
echo " [1-${#DISCOVERED_SKILLS[@]}] - Select specific skill(s) by number (comma-separated)"
echo " [q] - Quit"
echo ""
read -p "Your choice: " choice
case "$choice" in
a|A)
SELECTED_INDICES=($(seq 1 ${#DISCOVERED_SKILLS[@]}))
;;
q|Q)
print_info "Migration cancelled"
exit 0
;;
*)
SELECTED_INDICES=()
IFS=',' read -ra selections <<< "$choice"
for sel in "${selections[@]}"; do
sel=$(echo "$sel" | tr -d ' ')
if [[ "$sel" =~ ^[0-9]+$ ]] && [[ "$sel" -ge 1 ]] && [[ "$sel" -le ${#DISCOVERED_SKILLS[@]} ]]; then
SELECTED_INDICES+=("$sel")
else
print_warning "Invalid selection: $sel"
fi
done
if [[ ${#SELECTED_INDICES[@]} -eq 0 ]]; then
print_error "No valid skills selected"
exit 1
fi
;;
esac
}
# Resolve conflict for existing skill
resolve_conflict() {
local skill_name="$1"
local target_dir="$2"
local target_path="$target_dir/$skill_name"
if [[ ! -d "$target_path" ]]; then
echo "migrate"
return
fi
# If conflict strategy is set via flag, use it
if [[ -n "$CONFLICT_STRATEGY" ]]; then
# Print to stderr so it doesn't get captured in command substitution
print_info "Conflict for '$skill_name': using --conflict-strategy=$CONFLICT_STRATEGY" >&2
echo "$CONFLICT_STRATEGY"
return
fi
echo ""
print_warning "Skill already exists: $skill_name"
echo " Location: $target_path"
echo ""
echo "Options:"
echo " [o] - Overwrite (replace existing)"
echo " [s] - Skip (keep existing)"
echo " [b] - Backup (create backup, then overwrite)"
echo ""
while true; do
read -p "Your choice [o/s/b]: " conflict_choice
case "$conflict_choice" in
o|O)
echo "overwrite"
return
;;
s|S)
echo "skip"
return
;;
b|B)
echo "backup"
return
;;
*)
print_warning "Invalid choice. Please enter o, s, or b"
;;
esac
done
}
# Backup existing skill
backup_skill() {
local skill_name="$1"
local target_dir="$2"
local target_path="$target_dir/$skill_name"
local timestamp
timestamp=$(date +"%Y%m%d_%H%M%S")
local backup_path="${target_path}.backup.${timestamp}"
if [[ "$DRY_RUN" == false ]]; then
mv "$target_path" "$backup_path"
fi
BACKED_UP_SKILLS+=("$skill_name -> $backup_path")
echo "$backup_path"
}
# Migrate a single skill
migrate_skill() {
local skill_name="$1"
local source_path="$2"
local target_dir="$3"
local target_path="$target_dir/$skill_name"
print_info "Migrating: $skill_name"
# Check for conflicts
local resolution
resolution=$(resolve_conflict "$skill_name" "$target_dir")
case "$resolution" in
skip)
SKIPPED_SKILLS+=("$skill_name (user skipped)")
print_warning "Skipped: $skill_name"
return 0
;;
backup)
local backup_path
backup_path=$(backup_skill "$skill_name" "$target_dir")
print_info "Backed up to: $backup_path"
;;
migrate|overwrite)
# Proceed with migration
;;
esac
# Perform migration
if [[ "$DRY_RUN" == false ]]; then
if [[ -d "$target_path" ]]; then
rm -rf "$target_path"
fi
cp -r "$source_path" "$target_path"
fi
MIGRATED_SKILLS+=("$skill_name")
print_success "Migrated: $skill_name"
return 0
}
# Generate migration report
generate_report() {
echo ""
echo "========================================"
echo " MIGRATION REPORT"
echo "========================================"
echo ""
if [[ ${#MIGRATED_SKILLS[@]} -gt 0 ]]; then
print_success "Successfully Migrated (${#MIGRATED_SKILLS[@]}):"
for skill in "${MIGRATED_SKILLS[@]}"; do
echo "$skill"
done
echo ""
fi
if [[ ${#SKIPPED_SKILLS[@]} -gt 0 ]]; then
print_warning "Skipped (${#SKIPPED_SKILLS[@]}):"
for skill in "${SKIPPED_SKILLS[@]}"; do
echo "$skill"
done
echo ""
fi
if [[ ${#BACKED_UP_SKILLS[@]} -gt 0 ]]; then
print_info "Backed Up (${#BACKED_UP_SKILLS[@]}):"
for backup in "${BACKED_UP_SKILLS[@]}"; do
echo "$backup"
done
echo ""
fi
if [[ ${#ERROR_SKILLS[@]} -gt 0 ]]; then
print_error "Failed (${#ERROR_SKILLS[@]}):"
for skill in "${ERROR_SKILLS[@]}"; do
echo "$skill"
done
echo ""
fi
if [[ "$DRY_RUN" == true ]]; then
echo "Note: This was a DRY RUN. No changes were made."
echo " Run without --dry-run to perform actual migration."
fi
echo ""
echo "Target Directory: $TARGET_DIR"
echo "========================================"
}
# Main function
main() {
# Check for help flag early
for arg in "$@"; do
if [[ "$arg" == "-h" ]] || [[ "$arg" == "--help" ]]; then
show_help
exit 0
fi
done
echo "========================================"
echo " SKILL MIGRATOR"
echo "========================================"
echo ""
# Parse arguments
parse_args "$@"
# Validate source directory
if [[ ! -d "$SOURCE_DIR" ]]; then
print_error "Source directory does not exist: $SOURCE_DIR"
exit 1
fi
# Check dry run
if [[ "$DRY_RUN" == true ]]; then
print_warning "DRY RUN MODE - No changes will be made"
echo ""
fi
# Get target directory
TARGET_DIR=$(get_target_dir)
print_info "Source: $SOURCE_DIR"
print_info "Target: $TARGET_DIR ($TARGET_MODE)"
echo ""
# Discover skills
discover_skills "$SOURCE_DIR"
# Display discovered skills
display_discovered
# Select skills to migrate
SELECTED_INDICES=()
select_skills
# Confirm migration
echo ""
echo "Ready to migrate ${#SELECTED_INDICES[@]} skill(s)."
if [[ "$DRY_RUN" == false ]]; then
if [[ "$AUTO_CONFIRM" == true ]]; then
print_info "Auto-confirming (--yes flag set)"
else
read -p "Continue? [Y/n]: " confirm
if [[ "$confirm" =~ ^[Nn]$ ]]; then
print_info "Migration cancelled"
exit 0
fi
fi
fi
echo ""
# Migrate selected skills
local failed=0
for idx in "${SELECTED_INDICES[@]}"; do
local array_idx=$((idx - 1))
local skill_name="${DISCOVERED_SKILLS[$array_idx]}"
local source_path="${DISCOVERED_PATHS[$array_idx]}"
if ! migrate_skill "$skill_name" "$source_path" "$TARGET_DIR"; then
ERROR_SKILLS+=("$skill_name")
((failed++)) || true
fi
done
# Generate report
generate_report
# Exit with appropriate code
if [[ $failed -gt 0 ]]; then
exit 1
fi
exit 0
}
# Run main function
main "$@"