From 3fefae319ea30763ca2bc388232811b8a4a6c09e Mon Sep 17 00:00:00 2001 From: Salva Date: Sat, 18 Apr 2026 19:07:00 +0300 Subject: [PATCH] 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. --- .gitignore | 4 + README.md | 48 +++++++++ bin/opc-skills | 281 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/opc-skills diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44fa520 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.swp +*.swo +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..93ea421 --- /dev/null +++ b/README.md @@ -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.md + INDEX.json + INDEX.md +~/.config/opencode/skills/ # only currently-enabled skills + /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 # copy parked → active +opc-skills disable # remove from active (parked preserved) +opc-skills enable-category # 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. diff --git a/bin/opc-skills b/bin/opc-skills new file mode 100755 index 0000000..7b8e643 --- /dev/null +++ b/bin/opc-skills @@ -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 " >&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 " >&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 " >&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 enable single skill (copy parked → active) + disable disable single skill (remove from active; keep parked) + enable-category 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 "$@"