skillpull 0.2.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 (4) hide show
  1. package/README.md +120 -0
  2. package/install.sh +54 -0
  3. package/package.json +31 -0
  4. package/skillpull +924 -0
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # skillpull
2
+
3
+ Sync AI agent skills from Git repositories. One command, all tools.
4
+
5
+ ```bash
6
+ npx skillpull tianhaocui/ai-skills
7
+ ```
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ git clone https://github.com/tianhaocui/skillpull.git
13
+ cd skillpull
14
+ bash install.sh
15
+ ```
16
+
17
+ Or via npm:
18
+
19
+ ```bash
20
+ npm i -g skillpull
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # GitHub shortname
27
+ skillpull user/repo
28
+
29
+ # Pull a specific skill
30
+ skillpull user/repo my-skill
31
+
32
+ # List available skills in a repo
33
+ skillpull list user/repo
34
+
35
+ # Search GitHub for skill repos
36
+ skillpull search coding-standards
37
+ ```
38
+
39
+ ## Source Formats
40
+
41
+ | Format | Example |
42
+ |---|---|
43
+ | GitHub shortname | `user/repo` |
44
+ | Full URL | `https://github.com/user/repo` |
45
+ | SSH | `git@github.com:user/repo.git` |
46
+ | Alias | `@myalias` |
47
+ | Bare name | `my-skill` (requires registry) |
48
+
49
+ ## Targets
50
+
51
+ By default, skills install to `.claude/skills/`. Use flags to target other tools:
52
+
53
+ ```bash
54
+ skillpull user/repo --claude # .claude/skills/ (default)
55
+ skillpull user/repo --codex # .codex/skills/
56
+ skillpull user/repo --kiro # .kiro/skills/
57
+ skillpull user/repo --cursor # .cursor/rules/ (auto-converts to .mdc)
58
+ skillpull user/repo --all # All of the above
59
+ ```
60
+
61
+ ## Registry
62
+
63
+ Set a default skill repo so you can pull by skill name alone:
64
+
65
+ ```bash
66
+ # Set default registry
67
+ skillpull registry tianhaocui/ai-skills
68
+
69
+ # Now just use the skill name
70
+ skillpull my-skill
71
+ ```
72
+
73
+ ## Aliases
74
+
75
+ Save frequently used repos as aliases:
76
+
77
+ ```bash
78
+ skillpull alias add work git@github.com:myorg/skills.git
79
+ skillpull @work my-skill
80
+
81
+ skillpull alias list
82
+ skillpull alias rm work
83
+ ```
84
+
85
+ ## Options
86
+
87
+ | Flag | Description |
88
+ |---|---|
89
+ | `--global, -g` | Install to user-level directory |
90
+ | `--path <dir>` | Install to a custom directory |
91
+ | `--branch <ref>` | Use a specific branch/tag/commit |
92
+ | `--force, -f` | Overwrite existing skills |
93
+ | `--dry-run` | Preview without making changes |
94
+ | `--quiet, -q` | Suppress non-error output |
95
+
96
+ ## Skill Format
97
+
98
+ Each skill is a folder with a `SKILL.md` file:
99
+
100
+ ```
101
+ my-skill/
102
+ SKILL.md
103
+ scripts/ # optional
104
+ ```
105
+
106
+ `SKILL.md` uses YAML frontmatter:
107
+
108
+ ```markdown
109
+ ---
110
+ name: my-skill
111
+ version: 1.0.0
112
+ description: What this skill does
113
+ ---
114
+
115
+ Skill content here...
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
package/install.sh ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INSTALL_DIR="${SKILLPULL_INSTALL_DIR:-$HOME/.local/bin}"
5
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6
+ CONFIG_DIR="$HOME/.config/skillpull"
7
+ CONFIG_FILE="$CONFIG_DIR/config.json"
8
+
9
+ GREEN='\033[1;32m'; CYAN='\033[1;36m'; DIM='\033[2m'; RESET='\033[0m'
10
+
11
+ mkdir -p "$INSTALL_DIR"
12
+ cp "$SCRIPT_DIR/skillpull" "$INSTALL_DIR/skillpull"
13
+ chmod +x "$INSTALL_DIR/skillpull"
14
+
15
+ printf " ${GREEN}✓${RESET} Installed skillpull to %s\n" "$INSTALL_DIR/skillpull"
16
+
17
+ # Check if in PATH
18
+ if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
19
+ echo " Add to PATH: export PATH=\"$INSTALL_DIR:\$PATH\""
20
+ fi
21
+
22
+ # ── Setup default skill repository ──
23
+ echo ""
24
+ printf " ${CYAN}Set up a default skill repository?${RESET}\n"
25
+ printf " ${DIM}This lets you run 'skillpull <skill-name>' without typing the full URL.${RESET}\n"
26
+ printf " ${DIM}Supports: user/repo, full URL, or leave blank to skip.${RESET}\n"
27
+ echo ""
28
+ printf " Skill repo: "
29
+ read -r repo_input
30
+
31
+ if [[ -n "$repo_input" ]]; then
32
+ # Resolve shortname to full URL
33
+ resolved="$repo_input"
34
+ if [[ "$repo_input" != *"://"* && "$repo_input" != git@* ]]; then
35
+ if [[ "$repo_input" == */* && "$(echo "$repo_input" | tr -cd '/' | wc -c)" == "1" ]]; then
36
+ resolved="https://github.com/${repo_input}.git"
37
+ fi
38
+ fi
39
+
40
+ mkdir -p "$CONFIG_DIR"
41
+ if [[ -f "$CONFIG_FILE" ]]; then
42
+ sed -i "s|\"registry\":\"[^\"]*\"|\"registry\":\"${resolved}\"|" "$CONFIG_FILE"
43
+ else
44
+ echo "{\"aliases\":{},\"registry\":\"${resolved}\"}" > "$CONFIG_FILE"
45
+ fi
46
+
47
+ printf " ${GREEN}✓${RESET} Default registry set to: %s\n" "$resolved"
48
+ printf " ${DIM}Now you can run: skillpull <skill-name>${RESET}\n"
49
+ else
50
+ printf " ${DIM}Skipped. You can set it later: skillpull registry <user/repo>${RESET}\n"
51
+ fi
52
+
53
+ echo ""
54
+ printf " ${GREEN}Done!${RESET} Run 'skillpull --help' to get started.\n"
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "skillpull",
3
+ "version": "0.2.0",
4
+ "description": "Sync AI agent skills from Git repositories to Claude, Codex, Kiro, and Cursor",
5
+ "bin": {
6
+ "skillpull": "./skillpull"
7
+ },
8
+ "keywords": [
9
+ "ai",
10
+ "skills",
11
+ "claude",
12
+ "codex",
13
+ "kiro",
14
+ "cursor",
15
+ "agent",
16
+ "cli"
17
+ ],
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": ""
22
+ },
23
+ "files": [
24
+ "skillpull",
25
+ "install.sh"
26
+ ],
27
+ "os": [
28
+ "linux",
29
+ "darwin"
30
+ ]
31
+ }
package/skillpull ADDED
@@ -0,0 +1,924 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ VERSION="0.2.0"
5
+ MANIFEST_FILE=".skillpull.json"
6
+ TMPDIR_PREFIX="skillpull"
7
+ CONFIG_DIR="$HOME/.config/skillpull"
8
+ CONFIG_FILE="$CONFIG_DIR/config.json"
9
+
10
+ # ── Agent targets ──
11
+ # Format: PROJECT_DIR|GLOBAL_DIR|FORMAT
12
+ # FORMAT: skill = SKILL.md folder, mdc = .cursor/rules/*.mdc
13
+ declare -A AGENT_PROJECT AGENT_GLOBAL AGENT_FORMAT
14
+ AGENT_PROJECT=(
15
+ [claude]=".claude/skills"
16
+ [codex]=".codex/skills"
17
+ [kiro]=".kiro/skills"
18
+ [cursor]=".cursor/rules"
19
+ )
20
+ AGENT_GLOBAL=(
21
+ [claude]="$HOME/.claude/skills"
22
+ [codex]="$HOME/.codex/skills"
23
+ [kiro]="$HOME/.kiro/skills"
24
+ [cursor]="" # Cursor has no global file-based rules
25
+ )
26
+ AGENT_FORMAT=(
27
+ [claude]="skill"
28
+ [codex]="skill"
29
+ [kiro]="skill"
30
+ [cursor]="mdc"
31
+ )
32
+
33
+ DEFAULT_AGENT="claude"
34
+
35
+ # ── Colors ──
36
+ RED='\033[1;31m'; GREEN='\033[1;32m'; YELLOW='\033[1;33m'
37
+ CYAN='\033[1;36m'; DIM='\033[2m'; RESET='\033[0m'
38
+
39
+ info() { [[ "${QUIET:-0}" == "1" ]] || printf " ${GREEN}✓${RESET} %s\n" "$*"; }
40
+ warn() { printf " ${YELLOW}!${RESET} %s\n" "$*" >&2; }
41
+ err() { printf " ${RED}✗${RESET} %s\n" "$*" >&2; }
42
+ dim() { [[ "${QUIET:-0}" == "1" ]] || printf " ${DIM}%s${RESET}\n" "$*"; }
43
+
44
+ # ── Cleanup ──
45
+ _TMPDIR=""
46
+ cleanup() { [[ -n "${_TMPDIR:-}" && -d "${_TMPDIR:-}" ]] && rm -rf "$_TMPDIR" || true; }
47
+ trap cleanup EXIT
48
+
49
+ make_tmp() { _TMPDIR="$(mktemp -d "/tmp/${TMPDIR_PREFIX}.XXXXXX")"; }
50
+
51
+ # ── Config helpers ──
52
+ ensure_config() {
53
+ mkdir -p "$CONFIG_DIR"
54
+ [[ -f "$CONFIG_FILE" ]] || echo '{"aliases":{},"registry":""}' > "$CONFIG_FILE"
55
+ }
56
+
57
+ read_config_key() {
58
+ local key="$1"
59
+ [[ ! -f "$CONFIG_FILE" ]] && { echo ""; return 0; }
60
+ local val
61
+ val="$(grep -o "\"${key}\":\"[^\"]*\"" "$CONFIG_FILE" 2>/dev/null | head -1 | \
62
+ sed "s/\"${key}\":\"//" | sed 's/"$//')" || true
63
+ echo "$val"
64
+ }
65
+
66
+ read_alias() {
67
+ local name="$1"
68
+ [[ ! -f "$CONFIG_FILE" ]] && { echo ""; return 0; }
69
+ local val
70
+ val="$(sed -n '/"aliases"/,/}/p' "$CONFIG_FILE" 2>/dev/null | \
71
+ grep -o "\"${name}\":\"[^\"]*\"" | head -1 | \
72
+ sed "s/\"${name}\":\"//" | sed 's/"$//')" || true
73
+ echo "$val"
74
+ }
75
+
76
+ write_alias() {
77
+ local name="$1" url="$2"
78
+ ensure_config
79
+ local esc_url; esc_url="$(_json_escape "$url")"
80
+ if grep -q "\"${name}\"" "$CONFIG_FILE" 2>/dev/null; then
81
+ sed -i "s|\"${name}\":\"[^\"]*\"|\"${name}\":\"${esc_url}\"|" "$CONFIG_FILE"
82
+ else
83
+ # Insert into aliases object
84
+ sed -i "s|\"aliases\":{|\"aliases\":{\"${name}\":\"${esc_url}\",|" "$CONFIG_FILE"
85
+ # Clean trailing comma before }
86
+ sed -i 's/,}/}/g' "$CONFIG_FILE"
87
+ fi
88
+ }
89
+
90
+ remove_alias() {
91
+ local name="$1"
92
+ ensure_config
93
+ if ! grep -q "\"${name}\"" "$CONFIG_FILE" 2>/dev/null; then
94
+ err "Alias '$name' not found"
95
+ return 1
96
+ fi
97
+ # Remove the alias entry
98
+ sed -i "s|\"${name}\":\"[^\"]*\",\?||" "$CONFIG_FILE"
99
+ # Clean up double commas and trailing commas
100
+ sed -i 's/,,/,/g; s/,}/}/g; s/{,/{/g' "$CONFIG_FILE"
101
+ info "Alias '$name' removed"
102
+ }
103
+
104
+ list_aliases() {
105
+ ensure_config
106
+ local aliases
107
+ aliases="$(sed -n '/"aliases"/,/}/p' "$CONFIG_FILE" 2>/dev/null)" || true
108
+ local entries
109
+ entries="$(echo "$aliases" | grep -oP '"[^"]+":"[^"]+"' | grep -v '"aliases"')" || true
110
+ if [[ -z "$entries" ]]; then
111
+ warn "No aliases configured"
112
+ return 0
113
+ fi
114
+ printf "\n ${CYAN}%-20s %s${RESET}\n" "ALIAS" "URL"
115
+ printf " %-20s %s\n" "────────────────────" "────────────────────────────────────────"
116
+ echo "$entries" | while IFS= read -r line; do
117
+ local aname aurl
118
+ aname="$(echo "$line" | sed 's/"\([^"]*\)".*/\1/')"
119
+ aurl="$(echo "$line" | sed 's/[^:]*:"\([^"]*\)"/\1/')"
120
+ printf " %-20s %s\n" "$aname" "$aurl"
121
+ done
122
+ echo
123
+ }
124
+
125
+ set_registry() {
126
+ local url="$1"
127
+ ensure_config
128
+ local esc_url; esc_url="$(_json_escape "$url")"
129
+ sed -i "s|\"registry\":\"[^\"]*\"|\"registry\":\"${esc_url}\"|" "$CONFIG_FILE"
130
+ info "Default registry set to: $url"
131
+ }
132
+
133
+ # ── URL resolution ──
134
+ # Supports: full URL, user/repo shortname, @alias, bare skill name (from registry)
135
+ resolve_repo_url() {
136
+ local input="$1"
137
+
138
+ # @alias
139
+ if [[ "$input" == @* ]]; then
140
+ local alias_name="${input#@}"
141
+ local alias_url; alias_url="$(read_alias "$alias_name")"
142
+ if [[ -z "$alias_url" ]]; then
143
+ err "Alias '$alias_name' not found. Use 'skillpull alias list' to see aliases."
144
+ return 1
145
+ fi
146
+ echo "$alias_url"
147
+ return
148
+ fi
149
+
150
+ # Full URL (contains :// or starts with git@)
151
+ if [[ "$input" == *"://"* || "$input" == git@* ]]; then
152
+ echo "$input"
153
+ return
154
+ fi
155
+
156
+ # user/repo shortname (contains exactly one /)
157
+ if [[ "$input" == */* && "$(echo "$input" | tr -cd '/' | wc -c)" == "1" ]]; then
158
+ echo "https://github.com/${input}.git"
159
+ return
160
+ fi
161
+
162
+ # Bare name -> try registry
163
+ local registry; registry="$(read_config_key "registry")"
164
+ if [[ -n "$registry" ]]; then
165
+ # Treat registry as a repo URL, skill name as filter
166
+ echo "$registry"
167
+ return
168
+ fi
169
+
170
+ # Nothing matched
171
+ err "Cannot resolve '$input'. Use a URL, user/repo, or @alias."
172
+ return 1
173
+ }
174
+
175
+ # ── Frontmatter parsing ──
176
+ parse_frontmatter() {
177
+ local file="$1" key="$2"
178
+ local block
179
+ block="$(sed -n '1{/^---$/!q};1,/^---$/{/^---$/d;p}' "$file" 2>/dev/null)" || true
180
+ local val
181
+ val="$(echo "$block" | grep -E "^${key}:" | head -1 | \
182
+ sed "s/^${key}:[[:space:]]*//" | sed "s/^['\"]//;s/['\"]$//")" || true
183
+ echo "$val"
184
+ }
185
+
186
+ # ── Skill discovery ──
187
+ discover_skills() {
188
+ local dir="$1"
189
+ find "$dir" -name "SKILL.md" -type f \
190
+ -not -path "*/.git/*" \
191
+ -not -path "*/node_modules/*" \
192
+ -not -path "*/.skillpull.json" \
193
+ -exec dirname {} \; 2>/dev/null | sort -u
194
+ }
195
+
196
+ # ── SKILL.md → .mdc conversion (for Cursor) ──
197
+ convert_skill_to_mdc() {
198
+ local skill_md="$1" output_file="$2"
199
+ local name desc body
200
+ name="$(parse_frontmatter "$skill_md" "name")"
201
+ desc="$(parse_frontmatter "$skill_md" "description")"
202
+
203
+ # Extract body (everything after the second ---)
204
+ body="$(awk 'BEGIN{n=0} /^---$/{n++;next} n>=2{print}' "$skill_md")" || true
205
+
206
+ # Write .mdc with Cursor frontmatter
207
+ {
208
+ echo "---"
209
+ echo "description: \"${desc}\""
210
+ echo "globs: "
211
+ echo "alwaysApply: false"
212
+ echo "---"
213
+ echo ""
214
+ [[ -n "$name" ]] && echo "# ${name}" && echo ""
215
+ echo "$body"
216
+ } > "$output_file"
217
+ }
218
+
219
+ resolve_target_dir() {
220
+ local agent="$1" is_global="$2" custom_path="$3"
221
+ if [[ -n "$custom_path" ]]; then
222
+ echo "$custom_path"
223
+ return
224
+ fi
225
+ if [[ "$is_global" == "1" ]]; then
226
+ local gdir="${AGENT_GLOBAL[$agent]:-}"
227
+ if [[ -z "$gdir" ]]; then
228
+ err "$agent does not support global rules (no file-based global path)"
229
+ return 1
230
+ fi
231
+ echo "$gdir"
232
+ else
233
+ echo "${AGENT_PROJECT[$agent]}"
234
+ fi
235
+ }
236
+
237
+ skill_display_name() {
238
+ local skill_dir="$1"
239
+ local name
240
+ name="$(parse_frontmatter "$skill_dir/SKILL.md" "name")"
241
+ [[ -z "$name" ]] && name="$(basename "$skill_dir")"
242
+ echo "$name"
243
+ }
244
+
245
+ # ── Manifest (.skillpull.json) ──
246
+ read_manifest_entry() {
247
+ local manifest="$1" skill_name="$2" key="$3"
248
+ [[ ! -f "$manifest" ]] && { echo ""; return 0; }
249
+ local val
250
+ # Extract the line containing the skill, then pull out the key's value
251
+ val="$(grep "\"${skill_name}\"" "$manifest" 2>/dev/null | \
252
+ grep -o "\"${key}\":\"[^\"]*\"" | head -1 | \
253
+ sed "s/\"${key}\":\"//" | sed 's/"$//')" || true
254
+ echo "$val"
255
+ }
256
+
257
+ _json_escape() {
258
+ local s="$1"
259
+ s="${s//\\/\\\\}"
260
+ s="${s//\"/\\\"}"
261
+ echo "$s"
262
+ }
263
+
264
+ write_manifest_entry() {
265
+ local manifest="$1" skill_name="$2" repo="$3" branch="$4" commit="$5" version="$6"
266
+ local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
267
+ local esc_repo; esc_repo="$(_json_escape "$repo")"
268
+ local entry
269
+ entry=$(printf ' "%s": {"repo":"%s","branch":"%s","commit":"%s","version":"%s","pulled_at":"%s"}' \
270
+ "$skill_name" "$esc_repo" "$branch" "$commit" "$version" "$ts")
271
+
272
+ if [[ ! -f "$manifest" ]]; then
273
+ printf '{\n "skills": {\n%s\n }\n}\n' "$entry" > "$manifest"
274
+ return
275
+ fi
276
+
277
+ # Remove old entry if exists, then append new one
278
+ local tmp; tmp="$(mktemp)"
279
+ if grep -q "\"${skill_name}\"" "$manifest" 2>/dev/null; then
280
+ # Replace existing entry
281
+ awk -v name="\"${skill_name}\"" -v new="$entry" '
282
+ $0 ~ name { found=1; print new; next }
283
+ found && /}/ { found=0; next }
284
+ found { next }
285
+ { print }
286
+ ' "$manifest" > "$tmp"
287
+ mv "$tmp" "$manifest"
288
+ else
289
+ # Append before closing braces
290
+ sed -i '/"skills":/,$ {
291
+ /^ }/ i\'"$entry"',
292
+ }' "$manifest"
293
+ # Fix trailing comma before closing brace
294
+ sed -i ':a;N;$!ba;s/,\n }/\n }/g' "$manifest"
295
+ fi
296
+ }
297
+
298
+ # ── Clone helper ──
299
+ clone_repo() {
300
+ local url="$1" branch="${2:-}" tmpdir="$3"
301
+ local cmd=(git clone --depth 1 --quiet)
302
+ [[ -n "$branch" ]] && cmd+=(--branch "$branch")
303
+ cmd+=("$url" "$tmpdir")
304
+ local output
305
+ if ! output=$("${cmd[@]}" 2>&1); then
306
+ err "Git clone failed: $url"
307
+ [[ -n "$output" ]] && err "$output"
308
+ return 1
309
+ fi
310
+ }
311
+
312
+ get_commit() {
313
+ local dir="$1"
314
+ git -C "$dir" rev-parse --short HEAD 2>/dev/null || echo "unknown"
315
+ }
316
+
317
+ get_branch() {
318
+ local dir="$1"
319
+ git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown"
320
+ }
321
+
322
+ # ── Commands ──
323
+
324
+ cmd_pull() {
325
+ local repo_url="$1" skill_filter="${2:-}" target_dir="$3" force="${4:-0}" dry_run="${5:-0}" agent="${6:-claude}"
326
+ local fmt="${AGENT_FORMAT[$agent]:-skill}"
327
+ make_tmp
328
+ local tmpdir="$_TMPDIR"
329
+
330
+ dim "Cloning $repo_url ..."
331
+ clone_repo "$repo_url" "${BRANCH:-}" "$tmpdir" || return 1
332
+
333
+ local commit; commit="$(get_commit "$tmpdir")"
334
+ local branch; branch="$(get_branch "$tmpdir")"
335
+ local skills=()
336
+
337
+ while IFS= read -r d; do
338
+ [[ -n "$d" ]] && skills+=("$d")
339
+ done < <(discover_skills "$tmpdir")
340
+
341
+ if [[ ${#skills[@]} -eq 0 ]]; then
342
+ err "No skills found in repository"
343
+ return 1
344
+ fi
345
+
346
+ # Filter by name if specified
347
+ if [[ -n "$skill_filter" ]]; then
348
+ local matched=()
349
+ for sd in "${skills[@]}"; do
350
+ local sn; sn="$(skill_display_name "$sd")"
351
+ local dn; dn="$(basename "$sd")"
352
+ if [[ "$sn" == "$skill_filter" || "$dn" == "$skill_filter" ]]; then
353
+ matched+=("$sd")
354
+ fi
355
+ done
356
+ if [[ ${#matched[@]} -eq 0 ]]; then
357
+ err "Skill '$skill_filter' not found. Available skills:"
358
+ for sd in "${skills[@]}"; do
359
+ local sn; sn="$(skill_display_name "$sd")"
360
+ printf " - %s\n" "$sn" >&2
361
+ done
362
+ return 1
363
+ fi
364
+ skills=("${matched[@]}")
365
+ fi
366
+
367
+ mkdir -p "$target_dir"
368
+ local manifest="$target_dir/$MANIFEST_FILE"
369
+ local installed=0
370
+
371
+ for sd in "${skills[@]}"; do
372
+ local sn; sn="$(skill_display_name "$sd")"
373
+ local ver; ver="$(parse_frontmatter "$sd/SKILL.md" "version")"
374
+
375
+ if [[ "$fmt" == "mdc" ]]; then
376
+ # Cursor: convert SKILL.md → .mdc file
377
+ local dest="$target_dir/${sn}.mdc"
378
+
379
+ if [[ -f "$dest" && "$force" != "1" ]]; then
380
+ warn "Rule '$sn.mdc' already exists, use --force to overwrite"
381
+ continue
382
+ fi
383
+
384
+ if [[ "$dry_run" == "1" ]]; then
385
+ info "[dry-run] Would install: $sn -> $dest"
386
+ continue
387
+ fi
388
+
389
+ convert_skill_to_mdc "$sd/SKILL.md" "$dest"
390
+ write_manifest_entry "$manifest" "$sn" "$repo_url" "$branch" "$commit" "${ver:-}"
391
+ info "Installed: $sn -> $dest (converted to .mdc)"
392
+ else
393
+ # Claude/Codex/Kiro: copy skill folder as-is
394
+ local dest="$target_dir/$sn"
395
+
396
+ if [[ -d "$dest" && "$force" != "1" ]]; then
397
+ local old_ver; old_ver="$(parse_frontmatter "$dest/SKILL.md" "version")"
398
+ warn "Skill '$sn' already exists (${old_ver:-unknown} -> ${ver:-unknown}), use --force to overwrite"
399
+ continue
400
+ fi
401
+
402
+ if [[ "$dry_run" == "1" ]]; then
403
+ info "[dry-run] Would install: $sn -> $dest"
404
+ continue
405
+ fi
406
+
407
+ rm -rf "$dest"
408
+ cp -r "$sd" "$dest"
409
+ rm -rf "$dest/.git"
410
+ if [[ -d "$dest/scripts" ]]; then
411
+ chmod +x "$dest/scripts/"* 2>/dev/null || true
412
+ fi
413
+
414
+ write_manifest_entry "$manifest" "$sn" "$repo_url" "$branch" "$commit" "${ver:-}"
415
+ info "Installed: $sn${ver:+ (v$ver)} -> $dest"
416
+ fi
417
+ installed=$((installed + 1))
418
+ done
419
+
420
+ [[ "$dry_run" != "1" ]] && info "Done. $installed skill(s) installed to $target_dir"
421
+ }
422
+
423
+ cmd_list() {
424
+ local repo_url="$1"
425
+ make_tmp
426
+ local tmpdir="$_TMPDIR"
427
+
428
+ dim "Cloning $repo_url ..."
429
+ clone_repo "$repo_url" "${BRANCH:-}" "$tmpdir" || return 1
430
+
431
+ local skills=()
432
+ while IFS= read -r d; do
433
+ [[ -n "$d" ]] && skills+=("$d")
434
+ done < <(discover_skills "$tmpdir")
435
+
436
+ if [[ ${#skills[@]} -eq 0 ]]; then
437
+ err "No skills found in repository"
438
+ return 1
439
+ fi
440
+
441
+ printf "\n ${CYAN}%-25s %-10s %s${RESET}\n" "NAME" "VERSION" "DESCRIPTION"
442
+ printf " %-25s %-10s %s\n" "─────────────────────────" "──────────" "────────────────────────────────"
443
+
444
+ for sd in "${skills[@]}"; do
445
+ local sn; sn="$(skill_display_name "$sd")"
446
+ local ver; ver="$(parse_frontmatter "$sd/SKILL.md" "version")"
447
+ local desc; desc="$(parse_frontmatter "$sd/SKILL.md" "description")"
448
+ # Truncate description
449
+ [[ ${#desc} -gt 50 ]] && desc="${desc:0:47}..."
450
+ printf " %-25s %-10s %s\n" "$sn" "${ver:--}" "${desc:--}"
451
+ done
452
+ echo
453
+ }
454
+
455
+ cmd_installed() {
456
+ local target_dir="$1"
457
+ if [[ ! -d "$target_dir" ]]; then
458
+ warn "No skills directory found at $target_dir"
459
+ return 0
460
+ fi
461
+
462
+ local skills=()
463
+ while IFS= read -r d; do
464
+ [[ -n "$d" ]] && skills+=("$d")
465
+ done < <(discover_skills "$target_dir")
466
+
467
+ if [[ ${#skills[@]} -eq 0 ]]; then
468
+ warn "No skills installed in $target_dir"
469
+ return 0
470
+ fi
471
+
472
+ local manifest="$target_dir/$MANIFEST_FILE"
473
+ printf "\n ${CYAN}%-25s %-10s %-35s %s${RESET}\n" "NAME" "VERSION" "SOURCE" "PULLED"
474
+ printf " %-25s %-10s %-35s %s\n" "─────────────────────────" "──────────" "───────────────────────────────────" "──────────────────"
475
+
476
+ for sd in "${skills[@]}"; do
477
+ local sn; sn="$(skill_display_name "$sd")"
478
+ local ver; ver="$(parse_frontmatter "$sd/SKILL.md" "version")"
479
+ local repo; repo="$(read_manifest_entry "$manifest" "$sn" "repo")"
480
+ local pulled; pulled="$(read_manifest_entry "$manifest" "$sn" "pulled_at")"
481
+ # Truncate repo URL
482
+ [[ ${#repo} -gt 33 ]] && repo="${repo:0:30}..."
483
+ printf " %-25s %-10s %-35s %s\n" "$sn" "${ver:--}" "${repo:--}" "${pulled:--}"
484
+ done
485
+ echo
486
+ }
487
+
488
+ cmd_remove() {
489
+ local skill_name="$1" target_dir="$2" agent="${3:-claude}"
490
+ local fmt="${AGENT_FORMAT[$agent]:-skill}"
491
+
492
+ if [[ "$fmt" == "mdc" ]]; then
493
+ local dest="$target_dir/${skill_name}.mdc"
494
+ if [[ ! -f "$dest" ]]; then
495
+ err "Rule '$skill_name.mdc' not found in $target_dir"
496
+ return 1
497
+ fi
498
+ rm -f "$dest"
499
+ else
500
+ local dest="$target_dir/$skill_name"
501
+ if [[ ! -d "$dest" ]]; then
502
+ local found=""
503
+ while IFS= read -r d; do
504
+ local sn; sn="$(skill_display_name "$d")"
505
+ if [[ "$sn" == "$skill_name" ]]; then
506
+ found="$d"; break
507
+ fi
508
+ done < <(discover_skills "$target_dir")
509
+
510
+ if [[ -z "$found" ]]; then
511
+ err "Skill '$skill_name' not found in $target_dir"
512
+ return 1
513
+ fi
514
+ dest="$found"
515
+ fi
516
+ rm -rf "$dest"
517
+ fi
518
+
519
+ # Clean up manifest entry
520
+ local manifest="$target_dir/$MANIFEST_FILE"
521
+ if [[ -f "$manifest" ]] && grep -q "\"${skill_name}\"" "$manifest" 2>/dev/null; then
522
+ local tmp; tmp="$(mktemp)"
523
+ grep -v "\"${skill_name}\"" "$manifest" > "$tmp" || true
524
+ mv "$tmp" "$manifest"
525
+ sed -i ':a;N;$!ba;s/,\n }/\n }/g' "$manifest"
526
+ fi
527
+
528
+ info "Removed: $skill_name from $target_dir"
529
+ }
530
+
531
+ cmd_update() {
532
+ local target_dir="$1" force="$2" agent="$3"
533
+ local manifest="$target_dir/$MANIFEST_FILE"
534
+
535
+ if [[ ! -f "$manifest" ]]; then
536
+ err "No manifest found at $target_dir. Nothing to update."
537
+ return 1
538
+ fi
539
+
540
+ # Collect unique repos from manifest
541
+ local repos=()
542
+ while IFS= read -r repo; do
543
+ [[ -n "$repo" ]] && repos+=("$repo")
544
+ done < <(grep -oP '"repo":"[^"]*"' "$manifest" | sed 's/"repo":"//;s/"$//' | sort -u)
545
+
546
+ if [[ ${#repos[@]} -eq 0 ]]; then
547
+ warn "No skills tracked in manifest"
548
+ return 0
549
+ fi
550
+
551
+ local updated=0
552
+ for repo_url in "${repos[@]}"; do
553
+ # Find all skills from this repo
554
+ local skill_names=()
555
+ while IFS= read -r sn; do
556
+ [[ -n "$sn" ]] && skill_names+=("$sn")
557
+ done < <(grep -B1 "\"repo\":\"${repo_url}\"" "$manifest" | grep -oP '^\s*"[^"]+":' | sed 's/[" :]//g')
558
+
559
+ dim "Updating from $repo_url ..."
560
+ for sn in "${skill_names[@]}"; do
561
+ local old_commit; old_commit="$(read_manifest_entry "$manifest" "$sn" "commit")"
562
+ cmd_pull "$repo_url" "$sn" "$target_dir" "1" "0" "$agent" 2>/dev/null && {
563
+ local new_commit; new_commit="$(read_manifest_entry "$manifest" "$sn" "commit")"
564
+ if [[ "$old_commit" != "$new_commit" ]]; then
565
+ info "Updated: $sn ($old_commit -> $new_commit)"
566
+ updated=$((updated + 1))
567
+ else
568
+ dim "Already up to date: $sn"
569
+ fi
570
+ }
571
+ done
572
+ done
573
+
574
+ info "Done. $updated skill(s) updated."
575
+ }
576
+
577
+ cmd_push() {
578
+ local target_dir="$1" repo_url="$2" agent="$3"
579
+ local fmt="${AGENT_FORMAT[$agent]:-skill}"
580
+
581
+ if [[ ! -d "$target_dir" ]]; then
582
+ err "No skills directory found at $target_dir"
583
+ return 1
584
+ fi
585
+
586
+ # Resolve push target repo
587
+ if [[ -z "$repo_url" ]]; then
588
+ repo_url="$(read_config_key "registry")"
589
+ if [[ -z "$repo_url" ]]; then
590
+ err "No target repo specified and no registry set."
591
+ err "Usage: skillpull push <user/repo> or set registry first."
592
+ return 1
593
+ fi
594
+ fi
595
+
596
+ local resolved; resolved="$(resolve_repo_url "$repo_url")" || return 1
597
+
598
+ # Clone the remote repo
599
+ make_tmp
600
+ local tmpdir="$_TMPDIR"
601
+ dim "Cloning $resolved ..."
602
+
603
+ # Try clone; if empty repo, init fresh
604
+ if ! clone_repo "$resolved" "${BRANCH:-}" "$tmpdir" 2>/dev/null; then
605
+ git init --quiet "$tmpdir"
606
+ git -C "$tmpdir" remote add origin "$resolved"
607
+ fi
608
+
609
+ local branch; branch="$(git -C "$tmpdir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "master")"
610
+
611
+ # Discover local skills
612
+ local skills=()
613
+ if [[ "$fmt" == "mdc" ]]; then
614
+ while IFS= read -r f; do
615
+ [[ -n "$f" ]] && skills+=("$f")
616
+ done < <(find "$target_dir" -name "*.mdc" -type f 2>/dev/null)
617
+ else
618
+ while IFS= read -r d; do
619
+ [[ -n "$d" ]] && skills+=("$d")
620
+ done < <(discover_skills "$target_dir")
621
+ fi
622
+
623
+ if [[ ${#skills[@]} -eq 0 ]]; then
624
+ err "No skills found in $target_dir"
625
+ return 1
626
+ fi
627
+
628
+ # Copy skills into the cloned repo
629
+ local pushed=0
630
+ local skills_subdir="$tmpdir/skills"
631
+ mkdir -p "$skills_subdir"
632
+
633
+ for sd in "${skills[@]}"; do
634
+ local sn
635
+ if [[ "$fmt" == "mdc" ]]; then
636
+ sn="$(basename "$sd" .mdc)"
637
+ else
638
+ sn="$(skill_display_name "$sd")"
639
+ fi
640
+
641
+ local dest="$skills_subdir/$sn"
642
+ rm -rf "$dest"
643
+ cp -r "$sd" "$dest"
644
+ rm -rf "$dest/.git"
645
+ info "Staged: $sn"
646
+ pushed=$((pushed + 1))
647
+ done
648
+
649
+ # Commit and push
650
+ git -C "$tmpdir" add -A
651
+ if git -C "$tmpdir" diff --cached --quiet 2>/dev/null; then
652
+ dim "No changes to push. Remote is already up to date."
653
+ return 0
654
+ fi
655
+
656
+ git -C "$tmpdir" commit --quiet -m "skillpull push: $pushed skill(s) updated"
657
+ dim "Pushing to $resolved ..."
658
+
659
+ if ! git -C "$tmpdir" push origin "$branch" 2>&1; then
660
+ err "Push failed. Check your permissions for $resolved"
661
+ return 1
662
+ fi
663
+
664
+ info "Done. $pushed skill(s) pushed to $resolved"
665
+ }
666
+
667
+ cmd_alias() {
668
+ local subcmd="${1:-list}" name="${2:-}" url="${3:-}"
669
+ case "$subcmd" in
670
+ add)
671
+ [[ -z "$name" || -z "$url" ]] && { err "Usage: skillpull alias add <name> <url>"; return 1; }
672
+ local resolved; resolved="$(resolve_repo_url "$url")" || return 1
673
+ write_alias "$name" "$resolved"
674
+ info "Alias '@$name' -> $resolved"
675
+ ;;
676
+ rm|remove)
677
+ [[ -z "$name" ]] && { err "Usage: skillpull alias rm <name>"; return 1; }
678
+ remove_alias "$name"
679
+ ;;
680
+ list|"")
681
+ list_aliases
682
+ ;;
683
+ *)
684
+ err "Unknown alias subcommand: $subcmd"
685
+ return 1
686
+ ;;
687
+ esac
688
+ }
689
+
690
+ cmd_search() {
691
+ local keyword="$1"
692
+ [[ -z "$keyword" ]] && { err "Usage: skillpull search <keyword>"; return 1; }
693
+
694
+ dim "Searching GitHub for '$keyword' ..."
695
+ local encoded; encoded="$(echo "$keyword" | sed 's/ /+/g')"
696
+ local api_url="https://api.github.com/search/repositories?q=${encoded}+skills+in:name,description,readme&sort=stars&per_page=15"
697
+ local result
698
+ result="$(curl -sS -H "Accept: application/vnd.github.v3+json" "$api_url" 2>/dev/null)" || {
699
+ err "GitHub API request failed"
700
+ return 1
701
+ }
702
+
703
+ local count
704
+ count="$(echo "$result" | grep '"total_count"' | head -1 | grep -o '[0-9]*')" || true
705
+
706
+ if [[ "${count:-0}" == "0" ]]; then
707
+ warn "No results found for '$keyword'"
708
+ return 0
709
+ fi
710
+
711
+ printf "\n ${CYAN}%-30s %-8s %s${RESET}\n" "REPO" "STARS" "DESCRIPTION"
712
+ printf " %-30s %-8s %s\n" "──────────────────────────────" "────────" "────────────────────────────────"
713
+
714
+ # Parse multi-line JSON with grep/sed
715
+ local names stars descs
716
+ names="$(echo "$result" | grep '"full_name"' | sed 's/.*"full_name": *"//;s/".*//' | head -15)"
717
+ stars="$(echo "$result" | grep '"stargazers_count"' | sed 's/.*"stargazers_count": *//;s/,.*//' | head -15)"
718
+ descs="$(echo "$result" | grep '"description"' | grep -v '"description": *null' | sed 's/.*"description": *"//;s/".*//' | head -15)"
719
+
720
+ paste <(echo "$names") <(echo "$stars") <(echo "$descs") | while IFS=$'\t' read -r n s d; do
721
+ [[ -z "$n" ]] && continue
722
+ [[ ${#d} -gt 40 ]] && d="${d:0:37}..."
723
+ printf " %-30s %-8s %s\n" "$n" "${s:--}" "${d:--}"
724
+ done
725
+ echo
726
+ dim "Install with: skillpull <user/repo>"
727
+ }
728
+
729
+ # ── Usage ──
730
+ usage() {
731
+ cat <<'HELP'
732
+ skillpull — Sync AI agent skills from Git repositories
733
+
734
+ USAGE:
735
+ skillpull <source> [skill-name] [options]
736
+ skillpull list <source>
737
+ skillpull search <keyword>
738
+ skillpull alias add <name> <url>
739
+ skillpull alias list
740
+ skillpull alias rm <name>
741
+ skillpull registry <repo-url>
742
+ skillpull update [options]
743
+ skillpull push [target-repo] [options]
744
+ skillpull installed [--global]
745
+ skillpull remove <skill-name> [--global]
746
+
747
+ SOURCE FORMATS:
748
+ https://github.com/user/repo Full URL (GitHub, GitLab, any Git host)
749
+ git@github.com:user/repo.git SSH URL
750
+ user/repo GitHub shortname (auto-expands)
751
+ @myalias Alias (configured via 'alias add')
752
+
753
+ TARGETS:
754
+ --claude Claude Code (default): .claude/skills/
755
+ --codex OpenAI Codex CLI: .codex/skills/
756
+ --kiro Kiro: .kiro/skills/
757
+ --cursor Cursor: .cursor/rules/ (auto-converts to .mdc)
758
+ --all Install to all supported targets at once
759
+
760
+ OPTIONS:
761
+ --global, -g Install to user-level directory
762
+ --path <dir> Install to a custom directory
763
+ --branch <ref> Use a specific branch/tag/commit
764
+ --force, -f Overwrite existing skills
765
+ --dry-run Preview without making changes
766
+ --quiet, -q Suppress non-error output
767
+ --help, -h Show this help
768
+ --version Show version
769
+
770
+ EXAMPLES:
771
+ skillpull tianhaocui/ai-skills # GitHub shortname
772
+ skillpull @work plan-first-development --global # From alias
773
+ skillpull search coding-standards # Search GitHub
774
+ skillpull alias add work git@github.com:me/skills # Save alias
775
+ skillpull registry tianhaocui/ai-skills # Set default source
776
+ skillpull tianhaocui/ai-skills --all # All tools at once
777
+ skillpull tianhaocui/ai-skills --cursor # Convert to .mdc
778
+ skillpull update # Update all installed skills
779
+ skillpull push tianhaocui/ai-skills # Push local skills to remote
780
+ HELP
781
+ }
782
+
783
+ # ── Argument parsing & dispatch ──
784
+ main() {
785
+ local cmd="" repo_url="" skill_name="" custom_path=""
786
+ local force=0 dry_run=0 is_global=0 use_all=0
787
+ local agents=()
788
+ local alias_args=()
789
+ QUIET=0; BRANCH=""
790
+
791
+ [[ $# -eq 0 ]] && { usage; exit 0; }
792
+
793
+ while [[ $# -gt 0 ]]; do
794
+ case "$1" in
795
+ --help|-h) usage; exit 0;;
796
+ --version) echo "skillpull $VERSION"; exit 0 ;;
797
+ --global|-g) is_global=1; shift ;;
798
+ --path) [[ $# -lt 2 ]] && { err "--path requires a directory"; exit 1; }; custom_path="$2"; shift 2 ;;
799
+ --branch) [[ $# -lt 2 ]] && { err "--branch requires a ref"; exit 1; }; BRANCH="$2"; shift 2 ;;
800
+ --force|-f) force=1; shift ;;
801
+ --dry-run) dry_run=1; shift ;;
802
+ --quiet|-q) QUIET=1; shift ;;
803
+ --claude) agents+=("claude"); shift ;;
804
+ --codex) agents+=("codex"); shift ;;
805
+ --kiro) agents+=("kiro"); shift ;;
806
+ --cursor) agents+=("cursor"); shift ;;
807
+ --all) use_all=1; shift ;;
808
+ list) cmd="list"; shift ;;
809
+ installed) cmd="installed"; shift ;;
810
+ remove) cmd="remove"; shift ;;
811
+ update) cmd="update"; shift ;;
812
+ push) cmd="push"; shift ;;
813
+ search) cmd="search"; shift ;;
814
+ alias) cmd="alias"; shift
815
+ # Collect remaining args for alias subcommand
816
+ while [[ $# -gt 0 ]]; do alias_args+=("$1"); shift; done
817
+ ;;
818
+ registry) cmd="registry"; shift ;;
819
+ -*) err "Unknown option: $1"; usage; exit 1 ;;
820
+ *)
821
+ if [[ -z "$cmd" && -z "$repo_url" ]]; then
822
+ repo_url="$1"; cmd="pull"
823
+ elif [[ "$cmd" == "pull" && -n "$repo_url" && -z "$skill_name" ]]; then
824
+ skill_name="$1"
825
+ elif [[ "$cmd" == "list" && -z "$repo_url" ]]; then
826
+ repo_url="$1"
827
+ elif [[ "$cmd" == "remove" && -z "$skill_name" ]]; then
828
+ skill_name="$1"
829
+ elif [[ "$cmd" == "search" && -z "$skill_name" ]]; then
830
+ skill_name="$1"
831
+ elif [[ "$cmd" == "registry" && -z "$repo_url" ]]; then
832
+ repo_url="$1"
833
+ elif [[ "$cmd" == "push" && -z "$repo_url" ]]; then
834
+ repo_url="$1"
835
+ else
836
+ warn "Ignoring unexpected argument: $1"
837
+ fi
838
+ shift ;;
839
+ esac
840
+ done
841
+
842
+ # Default to claude if no agent specified
843
+ if [[ "$use_all" == "1" ]]; then
844
+ agents=("claude" "codex" "kiro" "cursor")
845
+ elif [[ ${#agents[@]} -eq 0 ]]; then
846
+ agents=("$DEFAULT_AGENT")
847
+ fi
848
+
849
+ case "${cmd:-}" in
850
+ pull|"")
851
+ [[ -z "$repo_url" ]] && { err "Missing source"; usage; exit 1; }
852
+ local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
853
+ # If resolve_repo_url returned registry URL for bare name, use original as skill filter
854
+ if [[ "$resolved" != "$repo_url" && "$repo_url" != @* && "$repo_url" != */* && "$repo_url" != *"://"* && "$repo_url" != git@* ]]; then
855
+ skill_name="$repo_url"
856
+ fi
857
+ for agent in "${agents[@]}"; do
858
+ local target_dir
859
+ target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue
860
+ dim "Target: $agent -> $target_dir"
861
+ cmd_pull "$resolved" "$skill_name" "$target_dir" "$force" "$dry_run" "$agent"
862
+ done
863
+ ;;
864
+ list)
865
+ [[ -z "$repo_url" ]] && { err "Missing source"; usage; exit 1; }
866
+ local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
867
+ cmd_list "$resolved"
868
+ ;;
869
+ search)
870
+ cmd_search "${skill_name:-}"
871
+ ;;
872
+ alias)
873
+ cmd_alias "${alias_args[@]}"
874
+ ;;
875
+ registry)
876
+ if [[ -z "$repo_url" ]]; then
877
+ local current; current="$(read_config_key "registry")"
878
+ if [[ -n "$current" ]]; then
879
+ info "Current registry: $current"
880
+ else
881
+ warn "No default registry set. Usage: skillpull registry <user/repo>"
882
+ fi
883
+ else
884
+ local resolved; resolved="$(resolve_repo_url "$repo_url")" || exit 1
885
+ set_registry "$resolved"
886
+ fi
887
+ ;;
888
+ installed)
889
+ for agent in "${agents[@]}"; do
890
+ local target_dir
891
+ target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue
892
+ dim "[$agent] $target_dir"
893
+ cmd_installed "$target_dir"
894
+ done
895
+ ;;
896
+ remove)
897
+ [[ -z "$skill_name" ]] && { err "Missing skill name"; usage; exit 1; }
898
+ for agent in "${agents[@]}"; do
899
+ local target_dir
900
+ target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue
901
+ cmd_remove "$skill_name" "$target_dir" "$agent"
902
+ done
903
+ ;;
904
+ update)
905
+ for agent in "${agents[@]}"; do
906
+ local target_dir
907
+ target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue
908
+ dim "[$agent] $target_dir"
909
+ cmd_update "$target_dir" "$force" "$agent"
910
+ done
911
+ ;;
912
+ push)
913
+ for agent in "${agents[@]}"; do
914
+ local target_dir
915
+ target_dir="$(resolve_target_dir "$agent" "$is_global" "$custom_path")" || continue
916
+ cmd_push "$target_dir" "$repo_url" "$agent"
917
+ done
918
+ ;;
919
+ *)
920
+ err "Unknown command: $cmd"; usage; exit 1 ;;
921
+ esac
922
+ }
923
+
924
+ main "$@"