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.
- package/README.md +120 -0
- package/install.sh +54 -0
- package/package.json +31 -0
- 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 "$@"
|