skillpull 0.3.6 → 0.4.2

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 +202 -18
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.2",
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.2"
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"
@@ -788,7 +951,11 @@ cmd_alias() {
788
951
  }
789
952
 
790
953
  cmd_search() {
791
- local keyword="$1"
954
+ local keyword="${1:-}"
955
+ if [[ -z "$keyword" && -t 0 ]]; then
956
+ printf " Search keyword: "
957
+ read -r keyword
958
+ fi
792
959
  [[ -z "$keyword" ]] && { err "Usage: skillpull search <keyword>"; return 1; }
793
960
 
794
961
  dim "Searching GitHub for '$keyword' ..."
@@ -907,10 +1074,11 @@ main() {
907
1074
  fi
908
1075
  echo ""
909
1076
  info "Run 'skillpull init' to set up your default skill repo."
910
- else
911
- usage
1077
+ exit 0
912
1078
  fi
913
- exit 0
1079
+
1080
+ # Already installed, no args -> treat as pull (will use registry + interactive select)
1081
+ cmd="pull"
914
1082
  fi
915
1083
 
916
1084
  while [[ $# -gt 0 ]]; do
@@ -973,7 +1141,16 @@ main() {
973
1141
 
974
1142
  case "${cmd:-}" in
975
1143
  pull|"")
976
- [[ -z "$repo_url" ]] && { err "Missing source"; usage; exit 1; }
1144
+ if [[ -z "$repo_url" ]]; then
1145
+ # No source given, try registry
1146
+ local reg; reg="$(read_config_key "registry")"
1147
+ if [[ -z "$reg" ]]; then
1148
+ err "No source specified and no registry set."
1149
+ err "Run 'skillpull init' to set a default skill repo."
1150
+ exit 1
1151
+ fi
1152
+ repo_url="$reg"
1153
+ fi
977
1154
  local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
978
1155
  # If resolve_repo_url returned registry URL for bare name, use original as skill filter
979
1156
  if [[ "$resolved" != "$repo_url" && "$repo_url" != @* && "$repo_url" != */* && "$repo_url" != *"://"* && "$repo_url" != git@* ]]; then
@@ -987,7 +1164,15 @@ main() {
987
1164
  done
988
1165
  ;;
989
1166
  list)
990
- [[ -z "$repo_url" ]] && { err "Missing source"; usage; exit 1; }
1167
+ if [[ -z "$repo_url" ]]; then
1168
+ local reg; reg="$(read_config_key "registry")"
1169
+ if [[ -z "$reg" ]]; then
1170
+ err "No source specified and no registry set."
1171
+ err "Run 'skillpull init' to set a default skill repo."
1172
+ exit 1
1173
+ fi
1174
+ repo_url="$reg"
1175
+ fi
991
1176
  local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
992
1177
  cmd_list "$resolved"
993
1178
  ;;
@@ -1025,7 +1210,6 @@ main() {
1025
1210
  done
1026
1211
  ;;
1027
1212
  remove)
1028
- [[ -z "$skill_name" ]] && { err "Missing skill name"; usage; exit 1; }
1029
1213
  for agent in "${agents[@]}"; do
1030
1214
  local target_dir
1031
1215
  target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue