skillpull 0.3.3 → 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 +5 -1
  3. package/skillpull +217 -32
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,10 +1,14 @@
1
1
  {
2
2
  "name": "skillpull",
3
- "version": "0.3.3",
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"
7
7
  },
8
+ "scripts": {
9
+ "postinstall": "echo '✓ skillpull installed. Run: skillpull init'",
10
+ "preuninstall": "echo '✓ skillpull uninstalled.'"
11
+ },
8
12
  "keywords": [
9
13
  "ai",
10
14
  "skills",
package/skillpull CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- VERSION="0.3.3"
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"
@@ -672,13 +835,32 @@ cmd_push() {
672
835
  cmd_uninstall() {
673
836
  local self; self="$(realpath "$0" 2>/dev/null || echo "$0")"
674
837
  local local_bin="${SKILLPULL_INSTALL_DIR:-$HOME/.local/bin}/skillpull"
838
+ local removed=0
675
839
 
676
840
  printf "\n ${CYAN}Uninstall skillpull${RESET}\n\n"
677
841
 
678
- # Remove local binary if exists
842
+ # Remove local binary (~/.local/bin)
679
843
  if [[ -f "$local_bin" ]]; then
680
844
  rm -f "$local_bin"
681
845
  info "Removed $local_bin"
846
+ removed=1
847
+ fi
848
+
849
+ # Remove npm global package (this is likely where the command lives)
850
+ if command -v npm &>/dev/null; then
851
+ local npm_pkg; npm_pkg="$(npm ls -g skillpull --parseable 2>/dev/null)" || true
852
+ if [[ -n "$npm_pkg" ]]; then
853
+ npm uninstall -g skillpull 2>/dev/null
854
+ info "Removed npm global package"
855
+ removed=1
856
+ fi
857
+ fi
858
+
859
+ # Remove the running binary itself if still exists and wasn't already cleaned
860
+ if [[ -f "$self" && "$self" != "$local_bin" ]]; then
861
+ rm -f "$self" 2>/dev/null || true
862
+ info "Removed $self"
863
+ removed=1
682
864
  fi
683
865
 
684
866
  # Remove config
@@ -693,21 +875,12 @@ cmd_uninstall() {
693
875
  fi
694
876
  fi
695
877
 
696
- # npm global uninstall
697
- if command -v npm &>/dev/null; then
698
- local npm_global; npm_global="$(npm root -g 2>/dev/null)/../bin/skillpull"
699
- if [[ -f "$npm_global" ]] || npm ls -g skillpull &>/dev/null 2>&1; then
700
- printf " Remove npm global package? (y/n) "
701
- read -r yn
702
- if [[ "$yn" == "y" || "$yn" == "Y" ]]; then
703
- npm uninstall -g skillpull 2>/dev/null
704
- info "npm global package removed"
705
- fi
706
- fi
707
- fi
708
-
709
878
  echo ""
710
- info "skillpull uninstalled."
879
+ if [[ "$removed" == "1" ]]; then
880
+ info "skillpull uninstalled."
881
+ else
882
+ warn "skillpull not found. Already uninstalled?"
883
+ fi
711
884
  }
712
885
 
713
886
  cmd_init() {
@@ -887,17 +1060,21 @@ main() {
887
1060
  local global_bin="${SKILLPULL_INSTALL_DIR:-$HOME/.local/bin}/skillpull"
888
1061
 
889
1062
  if [[ "$self" != "$global_bin" && ! -f "$global_bin" ]]; then
1063
+ info "Installing skillpull v${VERSION} ..."
890
1064
  mkdir -p "$(dirname "$global_bin")"
891
1065
  cp "$self" "$global_bin"
892
1066
  chmod +x "$global_bin"
893
- info "Installed skillpull to $global_bin"
1067
+ info "Installed to $global_bin"
894
1068
  if ! echo "$PATH" | tr ':' '\n' | grep -qx "$(dirname "$global_bin")"; then
895
1069
  warn "Add to PATH: export PATH=\"$(dirname "$global_bin"):\$PATH\""
896
1070
  fi
897
- else
898
- usage
1071
+ echo ""
1072
+ info "Run 'skillpull init' to set up your default skill repo."
1073
+ exit 0
899
1074
  fi
900
- exit 0
1075
+
1076
+ # Already installed, no args -> treat as pull (will use registry + interactive select)
1077
+ cmd="pull"
901
1078
  fi
902
1079
 
903
1080
  while [[ $# -gt 0 ]]; do
@@ -960,7 +1137,16 @@ main() {
960
1137
 
961
1138
  case "${cmd:-}" in
962
1139
  pull|"")
963
- [[ -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
964
1150
  local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
965
1151
  # If resolve_repo_url returned registry URL for bare name, use original as skill filter
966
1152
  if [[ "$resolved" != "$repo_url" && "$repo_url" != @* && "$repo_url" != */* && "$repo_url" != *"://"* && "$repo_url" != git@* ]]; then
@@ -1012,7 +1198,6 @@ main() {
1012
1198
  done
1013
1199
  ;;
1014
1200
  remove)
1015
- [[ -z "$skill_name" ]] && { err "Missing skill name"; usage; exit 1; }
1016
1201
  for agent in "${agents[@]}"; do
1017
1202
  local target_dir
1018
1203
  target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue