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 "$@"