skillpull 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/skillpull +188 -16
package/README.md CHANGED
@@ -115,6 +115,12 @@ description: What this skill does
115
115
  Skill content here...
116
116
  ```
117
117
 
118
+ ## Requirements
119
+
120
+ - macOS, Linux, or Windows (via [WSL](https://learn.microsoft.com/en-us/windows/wsl/) or [Git Bash](https://gitforwindows.org/))
121
+ - Git
122
+ - Bash 3+
123
+
118
124
  ## License
119
125
 
120
126
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillpull",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Sync AI agent skills from Git repositories to Claude, Codex, Kiro, and Cursor",
5
5
  "bin": {
6
6
  "skillpull": "./skillpull"
package/skillpull CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- VERSION="0.3.6"
4
+ VERSION="0.4.0"
5
5
  MANIFEST_FILE=".skillpull.json"
6
6
  TMPDIR_PREFIX="skillpull"
7
7
  CONFIG_DIR="$HOME/.config/skillpull"
@@ -46,6 +46,118 @@ warn() { printf " ${YELLOW}!${RESET} %s\n" "$*" >&2; }
46
46
  err() { printf " ${RED}✗${RESET} %s\n" "$*" >&2; }
47
47
  dim() { [[ "${QUIET:-0}" == "1" ]] || printf " ${DIM}%s${RESET}\n" "$*"; }
48
48
 
49
+ # ── Cross-platform sed -i ──
50
+ sedi() {
51
+ if [[ "$(uname)" == "Darwin" ]]; then
52
+ sed -i '' "$@"
53
+ else
54
+ sed -i "$@"
55
+ fi
56
+ }
57
+
58
+ # ── Interactive multi-select menu (bash 3 compatible) ──
59
+ # Usage: select_menu "prompt" item1 item2 item3 ...
60
+ # Sets SELECTED_ITEMS=("item1" "item3") with user's choices
61
+ SELECTED_ITEMS=()
62
+
63
+ select_menu() {
64
+ local prompt="$1"; shift
65
+ local items=("$@")
66
+ local count=${#items[@]}
67
+ local cursor=0
68
+ local i
69
+
70
+ # Track selected state with a simple string: "0" = unselected, "1" = selected
71
+ local selected=""
72
+ for ((i=0; i<count; i++)); do
73
+ selected="${selected}0"
74
+ done
75
+
76
+ SELECTED_ITEMS=()
77
+
78
+ # Save terminal state
79
+ local old_stty; old_stty="$(stty -g 2>/dev/null)" || true
80
+
81
+ # Hide cursor
82
+ printf '\033[?25l' >&2
83
+
84
+ # Print prompt
85
+ printf "\n ${CYAN}%s${RESET}\n" "$prompt" >&2
86
+ printf " ${DIM}↑↓ move Space select Enter confirm${RESET}\n\n" >&2
87
+
88
+ # Draw initial list
89
+ for ((i=0; i<count; i++)); do
90
+ local marker="[ ]"
91
+ if [[ "${selected:$i:1}" == "1" ]]; then marker="[x]"; fi
92
+ if [[ $i -eq $cursor ]]; then
93
+ printf " ${CYAN}> %s %s${RESET}\n" "$marker" "${items[$i]}" >&2
94
+ else
95
+ printf " %s %s\n" "$marker" "${items[$i]}" >&2
96
+ fi
97
+ done
98
+
99
+ # Input loop
100
+ while true; do
101
+ # Read single key
102
+ local key=""
103
+ IFS= read -rsn1 key 2>/dev/null || true
104
+
105
+ # Handle escape sequences (arrow keys)
106
+ if [[ "$key" == $'\033' ]]; then
107
+ local seq1="" seq2=""
108
+ IFS= read -rsn1 -t 0.1 seq1 2>/dev/null || true
109
+ IFS= read -rsn1 -t 0.1 seq2 2>/dev/null || true
110
+ key="${key}${seq1}${seq2}"
111
+ fi
112
+
113
+ case "$key" in
114
+ $'\033[A'|k) # Up
115
+ [[ $cursor -gt 0 ]] && cursor=$((cursor - 1))
116
+ ;;
117
+ $'\033[B'|j) # Down
118
+ [[ $cursor -lt $((count - 1)) ]] && cursor=$((cursor + 1))
119
+ ;;
120
+ ' ') # Space - toggle selection
121
+ if [[ "${selected:$cursor:1}" == "0" ]]; then
122
+ selected="${selected:0:$cursor}1${selected:$((cursor+1))}"
123
+ else
124
+ selected="${selected:0:$cursor}0${selected:$((cursor+1))}"
125
+ fi
126
+ ;;
127
+ '') # Enter - confirm
128
+ break
129
+ ;;
130
+ esac
131
+
132
+ # Redraw: move cursor up by $count lines
133
+ printf '\033[%dA' "$count" >&2
134
+
135
+ for ((i=0; i<count; i++)); do
136
+ printf '\033[2K' >&2 # Clear line
137
+ local marker="[ ]"
138
+ if [[ "${selected:$i:1}" == "1" ]]; then marker="[x]"; fi
139
+ if [[ $i -eq $cursor ]]; then
140
+ printf " ${CYAN}> %s %s${RESET}\n" "$marker" "${items[$i]}" >&2
141
+ else
142
+ printf " %s %s\n" "$marker" "${items[$i]}" >&2
143
+ fi
144
+ done
145
+ done
146
+
147
+ # Show cursor
148
+ printf '\033[?25h' >&2
149
+
150
+ # Restore terminal
151
+ [[ -n "$old_stty" ]] && stty "$old_stty" 2>/dev/null || true
152
+
153
+ # Collect selected items
154
+ for ((i=0; i<count; i++)); do
155
+ if [[ "${selected:$i:1}" == "1" ]]; then
156
+ SELECTED_ITEMS+=("${items[$i]}")
157
+ fi
158
+ done
159
+ }
160
+
49
161
  # ── Cleanup ──
50
162
  _TMPDIR=""
51
163
  cleanup() { [[ -n "${_TMPDIR:-}" && -d "${_TMPDIR:-}" ]] && rm -rf "$_TMPDIR" || true; }
@@ -83,12 +195,12 @@ write_alias() {
83
195
  ensure_config
84
196
  local esc_url; esc_url="$(_json_escape "$url")"
85
197
  if grep -q "\"${name}\"" "$CONFIG_FILE" 2>/dev/null; then
86
- sed -i "s|\"${name}\":\"[^\"]*\"|\"${name}\":\"${esc_url}\"|" "$CONFIG_FILE"
198
+ sedi "s|\"${name}\":\"[^\"]*\"|\"${name}\":\"${esc_url}\"|" "$CONFIG_FILE"
87
199
  else
88
200
  # Insert into aliases object
89
- sed -i "s|\"aliases\":{|\"aliases\":{\"${name}\":\"${esc_url}\",|" "$CONFIG_FILE"
201
+ sedi "s|\"aliases\":{|\"aliases\":{\"${name}\":\"${esc_url}\",|" "$CONFIG_FILE"
90
202
  # Clean trailing comma before }
91
- sed -i 's/,}/}/g' "$CONFIG_FILE"
203
+ sedi 's/,}/}/g' "$CONFIG_FILE"
92
204
  fi
93
205
  }
94
206
 
@@ -100,9 +212,9 @@ remove_alias() {
100
212
  return 1
101
213
  fi
102
214
  # Remove the alias entry
103
- sed -i "s|\"${name}\":\"[^\"]*\",\?||" "$CONFIG_FILE"
215
+ sedi "s|\"${name}\":\"[^\"]*\",\?||" "$CONFIG_FILE"
104
216
  # Clean up double commas and trailing commas
105
- sed -i 's/,,/,/g; s/,}/}/g; s/{,/{/g' "$CONFIG_FILE"
217
+ sedi 's/,,/,/g; s/,}/}/g; s/{,/{/g' "$CONFIG_FILE"
106
218
  info "Alias '$name' removed"
107
219
  }
108
220
 
@@ -131,7 +243,7 @@ set_registry() {
131
243
  local url="$1"
132
244
  ensure_config
133
245
  local esc_url; esc_url="$(_json_escape "$url")"
134
- sed -i "s|\"registry\":\"[^\"]*\"|\"registry\":\"${esc_url}\"|" "$CONFIG_FILE"
246
+ sedi "s|\"registry\":\"[^\"]*\"|\"registry\":\"${esc_url}\"|" "$CONFIG_FILE"
135
247
  info "Default registry set to: $url"
136
248
  }
137
249
 
@@ -292,11 +404,11 @@ write_manifest_entry() {
292
404
  mv "$tmp" "$manifest"
293
405
  else
294
406
  # Append before closing braces
295
- sed -i '/"skills":/,$ {
407
+ sedi '/"skills":/,$ {
296
408
  /^ }/ i\'"$entry"',
297
409
  }' "$manifest"
298
410
  # Fix trailing comma before closing brace
299
- sed -i ':a;N;$!ba;s/,\n }/\n }/g' "$manifest"
411
+ sedi ':a;N;$!ba;s/,\n }/\n }/g' "$manifest"
300
412
  fi
301
413
  }
302
414
 
@@ -367,6 +479,28 @@ cmd_pull() {
367
479
  return 1
368
480
  fi
369
481
  skills=("${matched[@]}")
482
+ elif [[ ${#skills[@]} -gt 1 && -t 0 ]]; then
483
+ # Multiple skills, no filter, interactive terminal -> let user choose
484
+ local names=()
485
+ for sd in "${skills[@]}"; do
486
+ names+=("$(skill_display_name "$sd")")
487
+ done
488
+ select_menu "Select skills to install:" "${names[@]}"
489
+ if [[ ${#SELECTED_ITEMS[@]} -eq 0 ]]; then
490
+ warn "No skills selected"
491
+ return 0
492
+ fi
493
+ local matched=()
494
+ for sd in "${skills[@]}"; do
495
+ local sn; sn="$(skill_display_name "$sd")"
496
+ for sel in "${SELECTED_ITEMS[@]}"; do
497
+ if [[ "$sn" == "$sel" ]]; then
498
+ matched+=("$sd")
499
+ break
500
+ fi
501
+ done
502
+ done
503
+ skills=("${matched[@]}")
370
504
  fi
371
505
 
372
506
  mkdir -p "$target_dir"
@@ -491,9 +625,38 @@ cmd_installed() {
491
625
  }
492
626
 
493
627
  cmd_remove() {
494
- local skill_name="$1" target_dir="$2" agent="${3:-claude}"
628
+ local skill_name="${1:-}" target_dir="$2" agent="${3:-claude}"
495
629
  local fmt; fmt="$(agent_format "$agent")"
496
630
 
631
+ # No skill name provided -> interactive select
632
+ if [[ -z "$skill_name" && -t 0 ]]; then
633
+ if [[ ! -d "$target_dir" ]]; then
634
+ warn "No skills directory found at $target_dir"
635
+ return 0
636
+ fi
637
+ local installed_skills=()
638
+ local installed_names=()
639
+ while IFS= read -r d; do
640
+ [[ -n "$d" ]] && installed_skills+=("$d") && installed_names+=("$(skill_display_name "$d")")
641
+ done < <(discover_skills "$target_dir")
642
+
643
+ if [[ ${#installed_skills[@]} -eq 0 ]]; then
644
+ warn "No skills installed in $target_dir"
645
+ return 0
646
+ fi
647
+
648
+ select_menu "Select skills to remove:" "${installed_names[@]}"
649
+ if [[ ${#SELECTED_ITEMS[@]} -eq 0 ]]; then
650
+ warn "No skills selected"
651
+ return 0
652
+ fi
653
+
654
+ for sel in "${SELECTED_ITEMS[@]}"; do
655
+ cmd_remove "$sel" "$target_dir" "$agent"
656
+ done
657
+ return 0
658
+ fi
659
+
497
660
  if [[ "$fmt" == "mdc" ]]; then
498
661
  local dest="$target_dir/${skill_name}.mdc"
499
662
  if [[ ! -f "$dest" ]]; then
@@ -527,7 +690,7 @@ cmd_remove() {
527
690
  local tmp; tmp="$(mktemp)"
528
691
  grep -v "\"${skill_name}\"" "$manifest" > "$tmp" || true
529
692
  mv "$tmp" "$manifest"
530
- sed -i ':a;N;$!ba;s/,\n }/\n }/g' "$manifest"
693
+ sedi ':a;N;$!ba;s/,\n }/\n }/g' "$manifest"
531
694
  fi
532
695
 
533
696
  info "Removed: $skill_name from $target_dir"
@@ -907,10 +1070,11 @@ main() {
907
1070
  fi
908
1071
  echo ""
909
1072
  info "Run 'skillpull init' to set up your default skill repo."
910
- else
911
- usage
1073
+ exit 0
912
1074
  fi
913
- exit 0
1075
+
1076
+ # Already installed, no args -> treat as pull (will use registry + interactive select)
1077
+ cmd="pull"
914
1078
  fi
915
1079
 
916
1080
  while [[ $# -gt 0 ]]; do
@@ -973,7 +1137,16 @@ main() {
973
1137
 
974
1138
  case "${cmd:-}" in
975
1139
  pull|"")
976
- [[ -z "$repo_url" ]] && { err "Missing source"; usage; exit 1; }
1140
+ if [[ -z "$repo_url" ]]; then
1141
+ # No source given, try registry
1142
+ local reg; reg="$(read_config_key "registry")"
1143
+ if [[ -z "$reg" ]]; then
1144
+ err "No source specified and no registry set."
1145
+ err "Run 'skillpull init' to set a default skill repo."
1146
+ exit 1
1147
+ fi
1148
+ repo_url="$reg"
1149
+ fi
977
1150
  local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
978
1151
  # If resolve_repo_url returned registry URL for bare name, use original as skill filter
979
1152
  if [[ "$resolved" != "$repo_url" && "$repo_url" != @* && "$repo_url" != */* && "$repo_url" != *"://"* && "$repo_url" != git@* ]]; then
@@ -1025,7 +1198,6 @@ main() {
1025
1198
  done
1026
1199
  ;;
1027
1200
  remove)
1028
- [[ -z "$skill_name" ]] && { err "Missing skill name"; usage; exit 1; }
1029
1201
  for agent in "${agents[@]}"; do
1030
1202
  local target_dir
1031
1203
  target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue