initial: opc-skills standalone tool
Moved out of ~/Documents/opencode-skills-parked/bin/ (data dir) so the tool has its own versioned home independent of the skill catalog it manages.
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# opc-skills
|
||||||
|
|
||||||
|
Small shell utility for toggling opencode skills between a curated "active" set and a full "parked" catalog. Keeps `~/.config/opencode/skills/` small and intentional while preserving the full skill library offline.
|
||||||
|
|
||||||
|
This is deliberately a quick, standalone tool — it is **not** a general AI persona manager. For agent / skill / persona management across multiple AI platforms (Claude Code, opencode, Gemini, Paperclip, ...), see the `personas` repo.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s ~/Documents/opc-skills/bin/opc-skills ~/.local/bin/opc-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `~/.local/bin` is on your `PATH`.
|
||||||
|
|
||||||
|
## Layout it expects
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Documents/opencode-skills-parked/ # full catalog — untracked data dir
|
||||||
|
<skill-name>/SKILL.md
|
||||||
|
INDEX.json
|
||||||
|
INDEX.md
|
||||||
|
~/.config/opencode/skills/ # only currently-enabled skills
|
||||||
|
<skill-name>/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Override via env:
|
||||||
|
- `OPC_PARKED` — parked catalog root (default `~/Documents/opencode-skills-parked`)
|
||||||
|
- `OPC_ACTIVE` — active skills root (default `~/.config/opencode/skills`)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
opc-skills status # counts of active vs parked
|
||||||
|
opc-skills list {active|parked|all} # skill folder names
|
||||||
|
opc-skills categories # prefix-based category counts
|
||||||
|
opc-skills enable <folder> # copy parked → active
|
||||||
|
opc-skills disable <folder> # remove from active (parked preserved)
|
||||||
|
opc-skills enable-category <prefix> # fzf multi-pick within a prefix
|
||||||
|
opc-skills pick # fzf: category → multi-select → enable
|
||||||
|
opc-skills search [query] # fzf fuzzy over name+description
|
||||||
|
opc-skills reindex # rebuild INDEX.json / INDEX.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`fzf` is required for the interactive commands.
|
||||||
|
|
||||||
|
## Known interaction
|
||||||
|
|
||||||
|
The `personas` repo's `build.py --install opencode` currently wipes and repopulates `~/.config/opencode/skills/` as part of installation, which destroys the active set curated here. Use `opc-skills` *after* any personas install, or skip the skills half of `build.py` installs.
|
||||||
281
bin/opc-skills
Executable file
281
bin/opc-skills
Executable file
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# opc-skills — opencode skill enable/disable manager
|
||||||
|
# Parked: ~/Documents/opencode-skills-parked
|
||||||
|
# Active: ~/.config/opencode/skills
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PARKED="${OPC_PARKED:-$HOME/Documents/opencode-skills-parked}"
|
||||||
|
ACTIVE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}"
|
||||||
|
INDEX_JSON="$PARKED/INDEX.json"
|
||||||
|
INDEX_MD="$PARKED/INDEX.md"
|
||||||
|
|
||||||
|
require_dirs() {
|
||||||
|
[ -d "$PARKED" ] || { echo "parked dir missing: $PARKED" >&2; exit 1; }
|
||||||
|
mkdir -p "$ACTIVE"
|
||||||
|
}
|
||||||
|
|
||||||
|
skill_dirs_in() {
|
||||||
|
# list skill folder names in a given dir (exclude bin/, files, hidden)
|
||||||
|
local base="$1"
|
||||||
|
[ -d "$base" ] || return 0
|
||||||
|
find "$base" -mindepth 1 -maxdepth 1 -type d \
|
||||||
|
! -name bin ! -name '.*' -printf '%f\n' | sort
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
require_dirs
|
||||||
|
local active parked
|
||||||
|
active=$(skill_dirs_in "$ACTIVE" | wc -l)
|
||||||
|
parked=$(skill_dirs_in "$PARKED" | wc -l)
|
||||||
|
printf "active : %5d (%s)\n" "$active" "$ACTIVE"
|
||||||
|
printf "parked : %5d (%s)\n" "$parked" "$PARKED"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
require_dirs
|
||||||
|
local which="${1:-parked}"
|
||||||
|
case "$which" in
|
||||||
|
active) skill_dirs_in "$ACTIVE" ;;
|
||||||
|
parked) skill_dirs_in "$PARKED" ;;
|
||||||
|
all) { skill_dirs_in "$ACTIVE"; skill_dirs_in "$PARKED"; } | sort -u ;;
|
||||||
|
*) echo "usage: opc-skills list {active|parked|all}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_categories() {
|
||||||
|
require_dirs
|
||||||
|
skill_dirs_in "$PARKED" \
|
||||||
|
| awk -F'-' '{print $1}' \
|
||||||
|
| sort | uniq -c | sort -rn
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_fzf() {
|
||||||
|
command -v fzf >/dev/null || { echo "fzf is required for this command" >&2; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# enable one skill by folder name
|
||||||
|
cmd_enable() {
|
||||||
|
require_dirs
|
||||||
|
local name="${1:-}"
|
||||||
|
[ -n "$name" ] || { echo "usage: opc-skills enable <folder>" >&2; exit 2; }
|
||||||
|
local src="$PARKED/$name" dst="$ACTIVE/$name"
|
||||||
|
[ -d "$src" ] || { echo "not parked: $name" >&2; exit 1; }
|
||||||
|
[ -d "$dst" ] && { echo "already active: $name"; exit 0; }
|
||||||
|
cp -r "$src" "$dst"
|
||||||
|
echo "enabled: $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# disable one active skill (move back to parked)
|
||||||
|
cmd_disable() {
|
||||||
|
require_dirs
|
||||||
|
local name="${1:-}"
|
||||||
|
[ -n "$name" ] || { echo "usage: opc-skills disable <folder>" >&2; exit 2; }
|
||||||
|
local src="$ACTIVE/$name" dst="$PARKED/$name"
|
||||||
|
[ -d "$src" ] || { echo "not active: $name" >&2; exit 1; }
|
||||||
|
if [ -d "$dst" ]; then
|
||||||
|
# parked copy exists; just remove active
|
||||||
|
rm -rf "$src"
|
||||||
|
echo "removed active copy (parked version kept): $name"
|
||||||
|
else
|
||||||
|
mv "$src" "$dst"
|
||||||
|
echo "disabled (moved to parked): $name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# batch enable by prefix/category (first word before '-')
|
||||||
|
cmd_enable_category() {
|
||||||
|
require_dirs
|
||||||
|
ensure_fzf
|
||||||
|
local prefix="${1:-}"
|
||||||
|
[ -n "$prefix" ] || { echo "usage: opc-skills enable-category <prefix>" >&2; exit 2; }
|
||||||
|
local -a matches
|
||||||
|
mapfile -t matches < <(skill_dirs_in "$PARKED" | awk -v p="$prefix" '$0 ~ "^" p "(-|$)"')
|
||||||
|
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills match prefix: $prefix" >&2; exit 1; }
|
||||||
|
echo "Matches (${#matches[@]}):"
|
||||||
|
local selection
|
||||||
|
selection=$(printf '%s\n' "${matches[@]}" | fzf --multi --height=70% \
|
||||||
|
--prompt="enable-category $prefix > " \
|
||||||
|
--header="TAB: toggle | ENTER: confirm | ESC: cancel")
|
||||||
|
[ -n "$selection" ] || { echo "cancelled"; exit 0; }
|
||||||
|
local n
|
||||||
|
while IFS= read -r n; do
|
||||||
|
[ -z "$n" ] && continue
|
||||||
|
cmd_enable "$n" || true
|
||||||
|
done <<< "$selection"
|
||||||
|
}
|
||||||
|
|
||||||
|
# interactive pick: category → multi-select skills
|
||||||
|
cmd_pick() {
|
||||||
|
require_dirs
|
||||||
|
ensure_fzf
|
||||||
|
local category
|
||||||
|
category=$(cmd_categories | fzf --prompt="category > " --height=50% \
|
||||||
|
--header="Pick a category (first column = count)" \
|
||||||
|
| awk '{print $2}')
|
||||||
|
[ -n "$category" ] || { echo "cancelled"; exit 0; }
|
||||||
|
# fzf preview: description from SKILL.md frontmatter
|
||||||
|
local selection
|
||||||
|
selection=$(skill_dirs_in "$PARKED" \
|
||||||
|
| awk -v p="$category" '$0 ~ "^" p "(-|$)"' \
|
||||||
|
| fzf --multi --height=80% \
|
||||||
|
--prompt="$category > " \
|
||||||
|
--header="TAB: toggle | ENTER: enable selected" \
|
||||||
|
--preview="sed -n '1,40p' \"$PARKED/{}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \
|
||||||
|
--preview-window=right:60%:wrap)
|
||||||
|
[ -n "$selection" ] || { echo "cancelled"; exit 0; }
|
||||||
|
local n
|
||||||
|
while IFS= read -r n; do
|
||||||
|
[ -z "$n" ] && continue
|
||||||
|
cmd_enable "$n" || true
|
||||||
|
done <<< "$selection"
|
||||||
|
}
|
||||||
|
|
||||||
|
# search by name+description via INDEX.json
|
||||||
|
cmd_search() {
|
||||||
|
require_dirs
|
||||||
|
ensure_fzf
|
||||||
|
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
|
||||||
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
||||||
|
local query="${1:-}"
|
||||||
|
# Build "folder\tdescription" lines
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq -r '.[] | "\(.folder)\t\(.description)"' "$INDEX_JSON" > "$tmp"
|
||||||
|
local selection
|
||||||
|
if [ -n "$query" ]; then
|
||||||
|
selection=$(fzf --query="$query" --multi --height=80% \
|
||||||
|
--delimiter='\t' --with-nth=1,2 \
|
||||||
|
--prompt="search > " \
|
||||||
|
--header="TAB: toggle | ENTER: enable selected" \
|
||||||
|
--preview="sed -n '1,40p' \"$PARKED/{1}/SKILL.md\" 2>/dev/null || echo '(not parked — maybe already active)'" \
|
||||||
|
--preview-window=right:60%:wrap < "$tmp")
|
||||||
|
else
|
||||||
|
selection=$(fzf --multi --height=80% \
|
||||||
|
--delimiter='\t' --with-nth=1,2 \
|
||||||
|
--prompt="search > " \
|
||||||
|
--header="TAB: toggle | ENTER: enable selected" \
|
||||||
|
--preview="sed -n '1,40p' \"$PARKED/{1}/SKILL.md\" 2>/dev/null || echo '(not parked — maybe already active)'" \
|
||||||
|
--preview-window=right:60%:wrap < "$tmp")
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
[ -n "$selection" ] || { echo "cancelled"; exit 0; }
|
||||||
|
local folder
|
||||||
|
while IFS=$'\t' read -r folder _; do
|
||||||
|
[ -z "$folder" ] && continue
|
||||||
|
cmd_enable "$folder" || true
|
||||||
|
done <<< "$selection"
|
||||||
|
}
|
||||||
|
|
||||||
|
# reindex: scan parked + active, regenerate INDEX.json / INDEX.md
|
||||||
|
cmd_reindex() {
|
||||||
|
require_dirs
|
||||||
|
python3 - "$PARKED" "$ACTIVE" "$INDEX_JSON" "$INDEX_MD" <<'PY'
|
||||||
|
import sys, os, re, json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
parked = Path(sys.argv[1])
|
||||||
|
active = Path(sys.argv[2])
|
||||||
|
out_json = Path(sys.argv[3])
|
||||||
|
out_md = Path(sys.argv[4])
|
||||||
|
|
||||||
|
def read_skill(d: Path):
|
||||||
|
sm = d / "SKILL.md"
|
||||||
|
name, desc = d.name, ""
|
||||||
|
if sm.exists():
|
||||||
|
try:
|
||||||
|
txt = sm.read_text(errors="replace")
|
||||||
|
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", txt, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
fm = m.group(1)
|
||||||
|
n = re.search(r'^name:\s*"?([^"\n]+?)"?\s*$', fm, re.MULTILINE)
|
||||||
|
dsc = re.search(r'^description:\s*"?(.+?)"?\s*$', fm, re.MULTILINE | re.DOTALL)
|
||||||
|
if n: name = n.group(1).strip()
|
||||||
|
if dsc: desc = dsc.group(1).strip()
|
||||||
|
except Exception as e:
|
||||||
|
desc = f"(read error: {e})"
|
||||||
|
else:
|
||||||
|
desc = "(no SKILL.md)"
|
||||||
|
return name, desc
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
def scan(base: Path, status: str):
|
||||||
|
if not base.exists(): return
|
||||||
|
for d in sorted(p for p in base.iterdir() if p.is_dir() and p.name not in ("bin",) and not p.name.startswith(".")):
|
||||||
|
name, desc = read_skill(d)
|
||||||
|
key = d.name
|
||||||
|
if key in seen:
|
||||||
|
seen[key]["status"] = "both"
|
||||||
|
else:
|
||||||
|
seen[key] = {"folder": d.name, "name": name, "description": desc, "status": status}
|
||||||
|
|
||||||
|
scan(active, "active")
|
||||||
|
scan(parked, "parked")
|
||||||
|
|
||||||
|
items = sorted(seen.values(), key=lambda x: x["folder"])
|
||||||
|
out_json.write_text(json.dumps(items, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
active_n = sum(1 for i in items if i["status"] in ("active", "both"))
|
||||||
|
parked_n = sum(1 for i in items if i["status"] in ("parked", "both"))
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# Opencode Skills — Index",
|
||||||
|
"",
|
||||||
|
f"**{len(items)} unique skills** — active: {active_n}, parked: {parked_n}.",
|
||||||
|
"",
|
||||||
|
f"Active: `{active}` | Parked: `{parked}`",
|
||||||
|
"",
|
||||||
|
"See `README.md` for the `opc-skills` CLI.",
|
||||||
|
"",
|
||||||
|
"| # | Status | Folder | Description |",
|
||||||
|
"|---|--------|--------|-------------|",
|
||||||
|
]
|
||||||
|
for i, it in enumerate(items, 1):
|
||||||
|
d = it["description"].replace("\n", " ").replace("|", "\\|")
|
||||||
|
if len(d) > 200: d = d[:197] + "..."
|
||||||
|
lines.append(f"| {i} | {it['status']} | `{it['folder']}` | {d} |")
|
||||||
|
out_md.write_text("\n".join(lines) + "\n")
|
||||||
|
print(f"reindexed: {len(items)} skills (active={active_n}, parked={parked_n})")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USG'
|
||||||
|
opc-skills — opencode skill manager
|
||||||
|
|
||||||
|
status counts of active vs parked
|
||||||
|
list {active|parked|all} list skill folder names
|
||||||
|
categories prefix-based category counts
|
||||||
|
enable <folder> enable single skill (copy parked → active)
|
||||||
|
disable <folder> disable single skill (remove from active; keep parked)
|
||||||
|
enable-category <prefix> fzf multi-pick within a prefix, then enable
|
||||||
|
pick fzf: choose category → multi-select → enable
|
||||||
|
search [query] fzf fuzzy search across name+description
|
||||||
|
reindex rebuild INDEX.json / INDEX.md
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
OPC_PARKED override parked dir (default: ~/Documents/opencode-skills-parked)
|
||||||
|
OPC_ACTIVE override active dir (default: ~/.config/opencode/skills)
|
||||||
|
USG
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local cmd="${1:-}"
|
||||||
|
[ -n "$cmd" ] || { usage; exit 2; }
|
||||||
|
shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
status) cmd_status "$@" ;;
|
||||||
|
list) cmd_list "$@" ;;
|
||||||
|
categories|cats) cmd_categories "$@" ;;
|
||||||
|
enable) cmd_enable "$@" ;;
|
||||||
|
disable) cmd_disable "$@" ;;
|
||||||
|
enable-category|enable-cat) cmd_enable_category "$@" ;;
|
||||||
|
pick) cmd_pick "$@" ;;
|
||||||
|
search) cmd_search "$@" ;;
|
||||||
|
reindex) cmd_reindex "$@" ;;
|
||||||
|
-h|--help|help) usage ;;
|
||||||
|
*) echo "unknown command: $cmd" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user