main
  1#!/usr/bin/env bash
  2# agent-skill-manager - Manage project-specific agent skills
  3#
  4# Usage:
  5#   agent-skill-manager init [dir]      Initialize skills for project
  6#   agent-skill-manager sync [dir]      Sync skills from config
  7#   agent-skill-manager clean [dir]     Remove project skills
  8#   agent-skill-manager status [dir]    Show active skills
  9#   agent-skill-manager detect [dir]    Detect project config
 10
 11set -euo pipefail
 12
 13GLOBAL_SKILLS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/claude/skills"
 14PROJECTS_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/agent-skills/projects.toml"
 15SKILLS_DIR=".claude/skills"
 16
 17# Colors
 18RED='\033[0;31m'
 19GREEN='\033[0;32m'
 20YELLOW='\033[1;33m'
 21BLUE='\033[0;34m'
 22NC='\033[0m'
 23
 24log_info() { echo -e "${BLUE}ℹ${NC} $*"; }
 25log_success() { echo -e "${GREEN}✓${NC} $*"; }
 26log_warning() { echo -e "${YELLOW}⚠${NC} $*"; }
 27log_error() { echo -e "${RED}✗${NC} $*" >&2; }
 28
 29# Detect project configuration by git remote or path
 30detect_project() {
 31    local project_dir="${1:-.}"
 32    
 33    cd "$project_dir" || exit 1
 34    
 35    if [[ ! -f "$PROJECTS_CONFIG" ]]; then
 36        log_error "Projects config not found: $PROJECTS_CONFIG"
 37        return 1
 38    fi
 39    
 40    # Try git remote first
 41    if git rev-parse --git-dir >/dev/null 2>&1; then
 42        local remote_url
 43        remote_url=$(git remote get-url origin 2>/dev/null || echo "")
 44        
 45        if [[ -n "$remote_url" ]]; then
 46            # Normalize URL
 47            remote_url=$(echo "$remote_url" | sed 's/\.git$//' | sed 's|git@github.com:|github.com/|' | sed 's|https://||')
 48            
 49            # Check patterns
 50            local project_key
 51            project_key=$(awk -F'"' '/^\[project\."/ {print $2}' "$PROJECTS_CONFIG" | while read -r pattern; do
 52                local regex="${pattern//\*/.*}"
 53                
 54                if [[ "$remote_url" =~ $regex ]]; then
 55                    echo "$pattern"
 56                    break
 57                fi
 58            done | head -1)
 59            
 60            if [[ -n "$project_key" ]]; then
 61                echo "$project_key"
 62                return 0
 63            fi
 64        fi
 65    fi
 66    
 67    # Try path-based matching
 68    local current_path
 69    current_path=$(pwd)
 70    
 71    local project_key
 72    project_key=$(awk -F'"' '/^\[project\."/ {print $2}' "$PROJECTS_CONFIG" | while read -r pattern; do
 73        if [[ "$pattern" == *"*"* ]]; then
 74            local regex="${pattern//\*/.*}"
 75            
 76            if [[ "$current_path" =~ $regex ]]; then
 77                echo "$pattern"
 78                break
 79            fi
 80        fi
 81    done | head -1)
 82    
 83    if [[ -n "$project_key" ]]; then
 84        echo "$project_key"
 85        return 0
 86    fi
 87    
 88    return 1
 89}
 90
 91# Parse required skills for a project from central config
 92parse_project_skills() {
 93    local project_key="$1"
 94    local in_project=0
 95    local in_array=0
 96    local skills=""
 97    
 98    while IFS= read -r line; do
 99        # Remove comments and trim
100        line=$(echo "$line" | sed 's/#.*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
101        
102        [[ -z "$line" ]] && continue
103        
104        # Check for project section
105        if [[ "$line" == "[project.\"$project_key\"]" ]]; then
106            in_project=1
107            continue
108        fi
109        
110        # Exit project section on next section header
111        if [[ $in_project -eq 1 ]] && [[ "$line" =~ ^\[.*\]$ ]]; then
112            break
113        fi
114        
115        # Start of required_skills array
116        if [[ $in_project -eq 1 ]] && [[ "$line" =~ ^required_skills[[:space:]]*=[[:space:]]*\[(.*) ]]; then
117            skills="${BASH_REMATCH[1]}"
118            # Check if array closes on same line
119            if [[ "$skills" =~ (.*)\][[:space:]]*$ ]]; then
120                # Single line array
121                skills="${BASH_REMATCH[1]}"
122                echo "$skills" | tr ',' '\n' | sed 's/^[[:space:]]*"//' | sed 's/"[[:space:]]*$//'
123                return 0
124            else
125                # Multi-line array - continue collecting
126                in_array=1
127                continue
128            fi
129        fi
130        
131        # Continue collecting array elements
132        if [[ $in_array -eq 1 ]]; then
133            if [[ "$line" =~ (.*)\][[:space:]]*$ ]]; then
134                # End of array
135                skills="$skills,${BASH_REMATCH[1]}"
136                echo "$skills" | tr ',' '\n' | sed 's/^[[:space:]]*"//' | sed 's/"[[:space:]]*$//' | sed '/^$/d'
137                return 0
138            else
139                # More array elements
140                skills="$skills,$line"
141            fi
142        fi
143    done < "$PROJECTS_CONFIG"
144}
145
146# Get project name from config
147get_project_name() {
148    local project_key="$1"
149    local in_project=0
150    
151    while IFS= read -r line; do
152        line=$(echo "$line" | sed 's/#.*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
153        [[ -z "$line" ]] && continue
154        
155        if [[ "$line" == "[project.\"$project_key\"]" ]]; then
156            in_project=1
157            continue
158        fi
159        
160        if [[ $in_project -eq 1 ]] && [[ "$line" =~ ^\[.*\]$ ]]; then
161            break
162        fi
163        
164        if [[ $in_project -eq 1 ]] && [[ "$line" =~ ^name[[:space:]]*=[[:space:]]*\"(.*)\" ]]; then
165            echo "${BASH_REMATCH[1]}"
166            return 0
167        fi
168    done < "$PROJECTS_CONFIG"
169    
170    echo "$project_key"
171}
172
173# Initialize project skills
174init_project_skills() {
175    local project_dir="${1:-.}"
176    
177    cd "$project_dir" || exit 1
178    
179    local project_key
180    project_key=$(detect_project "$project_dir") || {
181        log_error "No project configuration found for: $project_dir"
182        log_info "Add project to $PROJECTS_CONFIG"
183        return 1
184    }
185    
186    local project_name
187    project_name=$(get_project_name "$project_key")
188    
189    log_info "Initializing skills for: $project_name"
190    
191    mkdir -p "$SKILLS_DIR"
192    
193    local skills
194    mapfile -t skills < <(parse_project_skills "$project_key")
195    
196    if [[ ${#skills[@]} -eq 0 ]]; then
197        log_warning "No required skills found for: $project_name"
198        return 0
199    fi
200    
201    local copied=0
202    local skipped=0
203    
204    for skill in "${skills[@]}"; do
205        [[ -z "$skill" ]] && continue
206        
207        local source="$GLOBAL_SKILLS_DIR/$skill"
208        local target="$SKILLS_DIR/$skill"
209        
210        if [[ ! -d "$source" ]]; then
211            log_warning "Skill not found: $skill"
212            skipped=$((skipped + 1))
213            continue
214        fi
215        
216        if [[ -d "$target" ]]; then
217            log_info "Skill already exists: $skill"
218            skipped=$((skipped + 1))
219            continue
220        fi
221        
222        cp -r "$source" "$target"
223        log_success "Copied: $skill"
224        copied=$((copied + 1))
225    done
226    
227    log_success "Initialized $copied skills, skipped $skipped"
228    ensure_gitignore
229}
230
231# Sync skills
232sync_project_skills() {
233    local project_dir="${1:-.}"
234    
235    cd "$project_dir" || exit 1
236    
237    local project_key
238    project_key=$(detect_project "$project_dir") || {
239        return 0
240    }
241    
242    local project_name
243    project_name=$(get_project_name "$project_key")
244    
245    log_info "Syncing skills for: $project_name"
246    
247    local required_skills
248    mapfile -t required_skills < <(parse_project_skills "$project_key")
249    
250    declare -A required_map
251    for skill in "${required_skills[@]}"; do
252        [[ -z "$skill" ]] && continue
253        required_map["$skill"]=1
254    done
255    
256    for skill in "${required_skills[@]}"; do
257        [[ -z "$skill" ]] && continue
258        
259        local source="$GLOBAL_SKILLS_DIR/$skill"
260        local target="$SKILLS_DIR/$skill"
261        
262        if [[ ! -d "$source" ]]; then
263            log_warning "Skill not found: $skill"
264            continue
265        fi
266        
267        if [[ -d "$target" ]]; then
268            rsync -a --delete "$source/" "$target/"
269            log_success "Updated: $skill"
270        else
271            mkdir -p "$SKILLS_DIR"
272            cp -r "$source" "$target"
273            log_success "Added: $skill"
274        fi
275    done
276    
277    if [[ -d "$SKILLS_DIR" ]]; then
278        for existing in "$SKILLS_DIR"/*; do
279            [[ ! -d "$existing" ]] && continue
280            
281            local skill_name
282            skill_name=$(basename "$existing")
283            
284            if [[ ! -v required_map["$skill_name"] ]]; then
285                rm -rf "$existing"
286                log_info "Removed: $skill_name"
287            fi
288        done
289    fi
290    
291    ensure_gitignore
292}
293
294# Clean project skills
295clean_project_skills() {
296    local project_dir="${1:-.}"
297    
298    cd "$project_dir" || exit 1
299    
300    if [[ ! -d "$SKILLS_DIR" ]]; then
301        log_info "No skills directory found"
302        return 0
303    fi
304    
305    log_info "Cleaning skills"
306    rm -rf "$SKILLS_DIR"
307    log_success "Removed all project skills"
308}
309
310# Show status
311show_status() {
312    local project_dir="${1:-.}"
313    
314    cd "$project_dir" || exit 1
315    
316    echo "Project: $project_dir"
317    echo
318    
319    local project_key
320    project_key=$(detect_project "$project_dir") || {
321        echo "No project configuration found"
322        echo
323        if [[ -d "$SKILLS_DIR" ]]; then
324            echo "Project-local skills (manually managed):"
325            for skill in "$SKILLS_DIR"/*; do
326                [[ ! -d "$skill" ]] && continue
327                echo "  • $(basename "$skill")"
328            done
329        fi
330        return 0
331    }
332    
333    local project_name
334    project_name=$(get_project_name "$project_key")
335    
336    echo "Project: $project_name"
337    echo "Config: $project_key"
338    echo
339    
340    echo "Required skills:"
341    local skills
342    mapfile -t skills < <(parse_project_skills "$project_key")
343    
344    for skill in "${skills[@]}"; do
345        [[ -z "$skill" ]] && continue
346        
347        local source="$GLOBAL_SKILLS_DIR/$skill"
348        local target="$SKILLS_DIR/$skill"
349        
350        if [[ -d "$target" ]]; then
351            echo -e "  ${GREEN}✓${NC} $skill (active)"
352        else
353            if [[ -d "$source" ]]; then
354                echo -e "  ${YELLOW}○${NC} $skill (available)"
355            else
356                echo -e "  ${RED}✗${NC} $skill (not found)"
357            fi
358        fi
359    done
360}
361
362# Show detected project
363show_detect() {
364    local project_dir="${1:-.}"
365    
366    cd "$project_dir" || exit 1
367    
368    local project_key
369    project_key=$(detect_project "$project_dir") || {
370        echo "No project configuration detected"
371        return 1
372    }
373    
374    local project_name
375    project_name=$(get_project_name "$project_key")
376    
377    echo "Detected project: $project_name"
378    echo "Config key: $project_key"
379    echo "Required skills:"
380    parse_project_skills "$project_key" | while read -r skill; do
381        echo "  - $skill"
382    done
383}
384
385# Ensure gitignore
386ensure_gitignore() {
387    local gitignore=".gitignore"
388    
389    if [[ ! -f "$gitignore" ]]; then
390        echo ".claude/skills/" > "$gitignore"
391        log_success "Created .gitignore"
392    elif ! grep -q "^.claude/skills/" "$gitignore" 2>/dev/null; then
393        echo ".claude/skills/" >> "$gitignore"
394        log_success "Added .claude/skills/ to .gitignore"
395    fi
396}
397
398# Main
399main() {
400    local command="${1:-}"
401    shift || true
402    
403    case "$command" in
404        init)
405            init_project_skills "$@"
406            ;;
407        sync)
408            sync_project_skills "$@"
409            ;;
410        clean)
411            clean_project_skills "$@"
412            ;;
413        status)
414            show_status "$@"
415            ;;
416        detect)
417            show_detect "$@"
418            ;;
419        ""|--help|-h)
420            cat <<EOF
421agent-skill-manager - Manage project-specific Claude skills
422
423Usage:
424  agent-skill-manager init [dir]      Initialize skills for project
425  agent-skill-manager sync [dir]      Sync skills (update/add/remove)
426  agent-skill-manager clean [dir]     Remove all project skills
427  agent-skill-manager status [dir]    Show skill status
428  agent-skill-manager detect [dir]    Detect project configuration
429
430Configuration:
431  Central config: $PROJECTS_CONFIG
432  Global skills: $GLOBAL_SKILLS_DIR
433  Project skills: .claude/skills/ (gitignored)
434
435The central config maps git remotes and paths to required skills.
436Skills are automatically detected and synced based on your location.
437
438Examples:
439  # Detect what project you're in
440  agent-skill-manager detect
441
442  # Initialize skills for current project
443  agent-skill-manager init
444
445  # Sync skills after config changes
446  agent-skill-manager sync
447
448Git Hook Integration:
449  Add to .git/hooks/post-checkout:
450    #!/bin/bash
451    agent-skill-manager sync 2>/dev/null || true
452EOF
453            ;;
454        *)
455            log_error "Unknown command: $command"
456            exit 1
457            ;;
458    esac
459}
460
461main "$@"