- domains, subdomains, tags : frontmatter distribution counts
- enable-domain / disable-domain : bulk by 'domain:' (eg. cybersecurity)
- enable-subdomain / disable-subdomain : bulk by 'subdomain:' (eg. malware-analysis)
- enable-tag / disable-tag [--all] : multi-tag union (default) or intersection
- INDEX.json now exposes domain, subdomain, tags[]
- INDEX.md columns expanded; top-domains/subdomains/tags summaries
- PyYAML-first frontmatter parser (regex fallback) — parses 1027/1032 descriptions
and 758 skills' tags correctly (previous regex parser missed almost everything)
- enable/disable now incrementally update INDEX.json status so bulk ops stay
consistent without a full reindex
- jq --args bug fix in tag commands ('--' was leaking into ARGS.positional)
652 lines
24 KiB
Bash
Executable File
652 lines
24 KiB
Bash
Executable File
#!/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; }
|
|
}
|
|
|
|
ensure_jq() {
|
|
command -v jq >/dev/null || { echo "jq is required for this command" >&2; exit 1; }
|
|
}
|
|
|
|
# domain distribution (frontmatter `domain:` field) — uses INDEX.json
|
|
cmd_domains() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
jq -r '.[] | (.domain // "<none>")' "$INDEX_JSON" | sort | uniq -c | sort -rn
|
|
}
|
|
|
|
cmd_subdomains() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
jq -r '.[] | (.subdomain // "<none>")' "$INDEX_JSON" | sort | uniq -c | sort -rn
|
|
}
|
|
|
|
# tag distribution — flatten tags[] across all skills
|
|
cmd_tags() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
jq -r '.[] | (.tags // [])[]' "$INDEX_JSON" | sort | uniq -c | sort -rn
|
|
}
|
|
|
|
# bulk enable parked skills whose frontmatter `domain:` matches
|
|
cmd_enable_domain() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
local d="${1:-}"
|
|
[ -n "$d" ] || { echo "usage: opc-skills enable-domain <domain>" >&2; exit 2; }
|
|
local -a matches=()
|
|
mapfile -t matches < <(jq -r --arg d "$d" '
|
|
.[] | select((.domain // "") == $d and (.status == "parked" or .status == "both")) | .folder
|
|
' "$INDEX_JSON")
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in domain: $d" >&2; exit 1; }
|
|
echo "Will enable ${#matches[@]} skill(s) in domain '$d':"
|
|
printf ' %s\n' "${matches[@]}"
|
|
local n
|
|
for n in "${matches[@]}"; do cmd_enable "$n" || true; done
|
|
}
|
|
|
|
cmd_disable_domain() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
local d="${1:-}"
|
|
[ -n "$d" ] || { echo "usage: opc-skills disable-domain <domain>" >&2; exit 2; }
|
|
local -a matches=()
|
|
mapfile -t matches < <(jq -r --arg d "$d" '
|
|
.[] | select((.domain // "") == $d and (.status == "active" or .status == "both")) | .folder
|
|
' "$INDEX_JSON")
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in domain: $d" >&2; exit 1; }
|
|
echo "Will disable ${#matches[@]} skill(s) in domain '$d':"
|
|
printf ' %s\n' "${matches[@]}"
|
|
local n
|
|
for n in "${matches[@]}"; do cmd_disable "$n" || true; done
|
|
}
|
|
|
|
cmd_enable_subdomain() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
local s="${1:-}"
|
|
[ -n "$s" ] || { echo "usage: opc-skills enable-subdomain <subdomain>" >&2; exit 2; }
|
|
local -a matches=()
|
|
mapfile -t matches < <(jq -r --arg s "$s" '
|
|
.[] | select((.subdomain // "") == $s and (.status == "parked" or .status == "both")) | .folder
|
|
' "$INDEX_JSON")
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in subdomain: $s" >&2; exit 1; }
|
|
echo "Will enable ${#matches[@]} skill(s) in subdomain '$s':"
|
|
printf ' %s\n' "${matches[@]}"
|
|
local n
|
|
for n in "${matches[@]}"; do cmd_enable "$n" || true; done
|
|
}
|
|
|
|
cmd_disable_subdomain() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
local s="${1:-}"
|
|
[ -n "$s" ] || { echo "usage: opc-skills disable-subdomain <subdomain>" >&2; exit 2; }
|
|
local -a matches=()
|
|
mapfile -t matches < <(jq -r --arg s "$s" '
|
|
.[] | select((.subdomain // "") == $s and (.status == "active" or .status == "both")) | .folder
|
|
' "$INDEX_JSON")
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in subdomain: $s" >&2; exit 1; }
|
|
echo "Will disable ${#matches[@]} skill(s) in subdomain '$s':"
|
|
printf ' %s\n' "${matches[@]}"
|
|
local n
|
|
for n in "${matches[@]}"; do cmd_disable "$n" || true; done
|
|
}
|
|
|
|
# bulk enable by tag — supports multiple tags (intersection: --all) or union (default)
|
|
cmd_enable_tag() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
local mode="any"
|
|
if [ "${1:-}" = "--all" ]; then mode="all"; shift; fi
|
|
[ "$#" -gt 0 ] || { echo "usage: opc-skills enable-tag [--all] <tag>..." >&2; exit 2; }
|
|
local -a matches=()
|
|
if [ "$mode" = "all" ]; then
|
|
# intersection: all given tags must appear
|
|
local jq_filter='. | map(select((.status == "parked" or .status == "both") and ([.tags // [] | .[]] as $t | ($ARGS.positional | all(. as $x | $t | index($x)))))) | .[].folder'
|
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
|
else
|
|
# union: any given tag matches
|
|
local jq_filter='[$ARGS.positional[]] as $want | .[] | select((.status == "parked" or .status == "both") and ((.tags // []) | any(. as $t | $want | index($t)))) | .folder'
|
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
|
fi
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills with tag(s): $*" >&2; exit 1; }
|
|
echo "Will enable ${#matches[@]} skill(s) with tag(s) [$mode]: $*"
|
|
printf ' %s\n' "${matches[@]}"
|
|
local n
|
|
for n in "${matches[@]}"; do cmd_enable "$n" || true; done
|
|
}
|
|
|
|
cmd_disable_tag() {
|
|
require_dirs
|
|
ensure_jq
|
|
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
|
|
local mode="any"
|
|
if [ "${1:-}" = "--all" ]; then mode="all"; shift; fi
|
|
[ "$#" -gt 0 ] || { echo "usage: opc-skills disable-tag [--all] <tag>..." >&2; exit 2; }
|
|
local -a matches=()
|
|
if [ "$mode" = "all" ]; then
|
|
local jq_filter='. | map(select((.status == "active" or .status == "both") and ([.tags // [] | .[]] as $t | ($ARGS.positional | all(. as $x | $t | index($x)))))) | .[].folder'
|
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
|
else
|
|
local jq_filter='[$ARGS.positional[]] as $want | .[] | select((.status == "active" or .status == "both") and ((.tags // []) | any(. as $t | $want | index($t)))) | .folder'
|
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
|
fi
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills with tag(s): $*" >&2; exit 1; }
|
|
echo "Will disable ${#matches[@]} skill(s) with tag(s) [$mode]: $*"
|
|
printf ' %s\n' "${matches[@]}"
|
|
local n
|
|
for n in "${matches[@]}"; do cmd_disable "$n" || true; done
|
|
}
|
|
|
|
# Sync loose sibling files (e.g. _platform-mapping.md) from PARKED → ACTIVE so
|
|
# skills that use `../<file>` relative refs keep resolving after install.
|
|
sync_shared_refs() {
|
|
local f
|
|
for f in _platform-mapping.md; do
|
|
if [ -f "$PARKED/$f" ] && [ ! -f "$ACTIVE/$f" ]; then
|
|
cp "$PARKED/$f" "$ACTIVE/$f"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# incremental INDEX status update (cheap; avoids full reindex per enable/disable)
|
|
update_index_status() {
|
|
local folder="$1" new_status="$2"
|
|
[ -f "$INDEX_JSON" ] || return 0
|
|
command -v jq >/dev/null || return 0
|
|
local tmp; tmp=$(mktemp)
|
|
jq --arg f "$folder" --arg s "$new_status" \
|
|
'(.[] | select(.folder == $f) | .status) = $s' \
|
|
"$INDEX_JSON" > "$tmp" && mv "$tmp" "$INDEX_JSON"
|
|
}
|
|
|
|
# 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"
|
|
sync_shared_refs
|
|
update_index_status "$name" "both"
|
|
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"
|
|
update_index_status "$name" "parked"
|
|
echo "removed active copy (parked version kept): $name"
|
|
else
|
|
mv "$src" "$dst"
|
|
update_index_status "$name" "parked"
|
|
echo "disabled (moved to parked): $name"
|
|
fi
|
|
}
|
|
|
|
# disable all active skills (with confirmation; -y/--yes to skip prompt)
|
|
cmd_disable_all() {
|
|
require_dirs
|
|
local force=0
|
|
if [ "${1:-}" = "-y" ] || [ "${1:-}" = "--yes" ]; then
|
|
force=1
|
|
fi
|
|
local -a actives
|
|
mapfile -t actives < <(skill_dirs_in "$ACTIVE")
|
|
[ "${#actives[@]}" -gt 0 ] || { echo "no active skills"; exit 0; }
|
|
echo "Will disable ${#actives[@]} active skill(s):"
|
|
printf ' %s\n' "${actives[@]}"
|
|
if [ "$force" -ne 1 ]; then
|
|
printf "Proceed? [y/N] "
|
|
read -r ans </dev/tty || ans=""
|
|
case "$ans" in y|Y|yes|YES) ;; *) echo "cancelled"; exit 0 ;; esac
|
|
fi
|
|
local n
|
|
for n in "${actives[@]}"; do
|
|
cmd_disable "$n" || true
|
|
done
|
|
}
|
|
|
|
# batch disable by prefix/category — fzf multi-pick within ACTIVE
|
|
cmd_disable_category() {
|
|
require_dirs
|
|
ensure_fzf
|
|
local prefix="${1:-}"
|
|
[ -n "$prefix" ] || { echo "usage: opc-skills disable-category <prefix>" >&2; exit 2; }
|
|
local -a matches
|
|
mapfile -t matches < <(skill_dirs_in "$ACTIVE" | awk -v p="$prefix" '$0 ~ "^" p "(-|$)"')
|
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills match prefix: $prefix" >&2; exit 1; }
|
|
echo "Active matches (${#matches[@]}):"
|
|
local selection
|
|
selection=$(printf '%s\n' "${matches[@]}" | fzf --multi --height=70% \
|
|
--prompt="disable-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_disable "$n" || true
|
|
done <<< "$selection"
|
|
}
|
|
|
|
# interactive pick (active only): category → multi-select skills to disable
|
|
cmd_disable_pick() {
|
|
require_dirs
|
|
ensure_fzf
|
|
local active_count
|
|
active_count=$(skill_dirs_in "$ACTIVE" | wc -l)
|
|
[ "$active_count" -gt 0 ] || { echo "no active skills"; exit 0; }
|
|
# Active categories (by prefix)
|
|
local cats
|
|
cats=$(skill_dirs_in "$ACTIVE" | awk -F'-' '{print $1}' | sort | uniq -c | sort -rn)
|
|
local category
|
|
category=$(printf '%s\n' "$cats" | fzf --prompt="active-category > " --height=50% \
|
|
--header="Pick a category of ACTIVE skills to disable" \
|
|
| awk '{print $2}')
|
|
[ -n "$category" ] || { echo "cancelled"; exit 0; }
|
|
local selection
|
|
selection=$(skill_dirs_in "$ACTIVE" \
|
|
| awk -v p="$category" '$0 ~ "^" p "(-|$)"' \
|
|
| fzf --multi --height=80% \
|
|
--prompt="disable $category > " \
|
|
--header="TAB: toggle | ENTER: disable selected" \
|
|
--preview="sed -n '1,40p' \"$ACTIVE/{}/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_disable "$n" || true
|
|
done <<< "$selection"
|
|
}
|
|
|
|
# fzf search across active skills (uses INDEX.json filtered by status active|both)
|
|
cmd_disable_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:-}"
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
# Only active or both
|
|
jq -r '.[] | select(.status=="active" or .status=="both") | "\(.folder)\t\(.description)"' "$INDEX_JSON" > "$tmp"
|
|
[ -s "$tmp" ] || { rm -f "$tmp"; echo "no active skills indexed (try: opc-skills reindex)"; exit 0; }
|
|
local selection
|
|
if [ -n "$query" ]; then
|
|
selection=$(fzf --query="$query" --multi --height=80% \
|
|
--delimiter='\t' --with-nth=1,2 \
|
|
--prompt="disable-search > " \
|
|
--header="TAB: toggle | ENTER: disable selected" \
|
|
--preview="sed -n '1,40p' \"$ACTIVE/{1}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \
|
|
--preview-window=right:60%:wrap < "$tmp")
|
|
else
|
|
selection=$(fzf --multi --height=80% \
|
|
--delimiter='\t' --with-nth=1,2 \
|
|
--prompt="disable-search > " \
|
|
--header="TAB: toggle | ENTER: disable selected" \
|
|
--preview="sed -n '1,40p' \"$ACTIVE/{1}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \
|
|
--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_disable "$folder" || true
|
|
done <<< "$selection"
|
|
}
|
|
|
|
# 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
|
|
|
|
try:
|
|
import yaml
|
|
HAVE_YAML = True
|
|
except Exception:
|
|
HAVE_YAML = False
|
|
|
|
parked = Path(sys.argv[1])
|
|
active = Path(sys.argv[2])
|
|
out_json = Path(sys.argv[3])
|
|
out_md = Path(sys.argv[4])
|
|
|
|
def parse_frontmatter(fm: str) -> dict:
|
|
if HAVE_YAML:
|
|
try:
|
|
data = yaml.safe_load(fm)
|
|
if isinstance(data, dict): return data
|
|
except Exception:
|
|
pass
|
|
# regex fallback (best-effort, single-line scalars)
|
|
out = {}
|
|
for key in ("name", "description", "domain", "subdomain"):
|
|
m = re.search(rf'^{key}:\s*"?(.+?)"?\s*$', fm, re.MULTILINE)
|
|
if m: out[key] = m.group(1).strip()
|
|
tm = re.search(r'^tags:\s*\n((?:[ \t]*-\s*[^\n]+\n)+)', fm, re.MULTILINE)
|
|
if tm:
|
|
out["tags"] = [re.sub(r'^[ \t]*-\s*', '', l).strip().strip('"\'')
|
|
for l in tm.group(1).splitlines() if l.strip()]
|
|
return out
|
|
|
|
def read_skill(d: Path):
|
|
sm = d / "SKILL.md"
|
|
name = d.name
|
|
desc, domain, subdomain = "", "", ""
|
|
tags = []
|
|
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_data = parse_frontmatter(m.group(1))
|
|
name = str(fm_data.get("name") or d.name).strip()
|
|
desc = re.sub(r"\s+", " ", str(fm_data.get("description") or "").strip())
|
|
domain = str(fm_data.get("domain") or "").strip()
|
|
subdomain = str(fm_data.get("subdomain") or "").strip()
|
|
t = fm_data.get("tags") or []
|
|
if isinstance(t, list):
|
|
tags = [str(x).strip() for x in t if str(x).strip()]
|
|
elif isinstance(t, str):
|
|
tags = [t.strip()] if t.strip() else []
|
|
except Exception as e:
|
|
desc = f"(read error: {e})"
|
|
else:
|
|
desc = "(no SKILL.md)"
|
|
return {
|
|
"folder": d.name, "name": name, "description": desc,
|
|
"domain": domain, "subdomain": subdomain, "tags": tags,
|
|
}
|
|
|
|
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(".")):
|
|
rec = read_skill(d)
|
|
key = d.name
|
|
if key in seen:
|
|
seen[key]["status"] = "both"
|
|
else:
|
|
rec["status"] = status
|
|
seen[key] = rec
|
|
|
|
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"))
|
|
|
|
from collections import Counter
|
|
dom_c = Counter(i["domain"] for i in items if i["domain"])
|
|
sub_c = Counter(i["subdomain"] for i in items if i["subdomain"])
|
|
tag_c = Counter(t for i in items for t in i.get("tags", []))
|
|
top_doms = ", ".join(f"{k}:{v}" for k,v in dom_c.most_common(10)) or "(none)"
|
|
top_subs = ", ".join(f"{k}:{v}" for k,v in sub_c.most_common(10)) or "(none)"
|
|
top_tags = ", ".join(f"{k}:{v}" for k,v in tag_c.most_common(15)) or "(none)"
|
|
|
|
lines = [
|
|
"# Opencode Skills — Index",
|
|
"",
|
|
f"**{len(items)} unique skills** — active: {active_n}, parked: {parked_n}.",
|
|
f"Top domains — {top_doms}.",
|
|
f"Top subdomains — {top_subs}.",
|
|
f"Top tags — {top_tags}.",
|
|
"",
|
|
f"Active: `{active}` | Parked: `{parked}`",
|
|
"",
|
|
"See `README.md` for the `opc-skills` CLI.",
|
|
"",
|
|
"| # | Status | Domain | Subdomain | Folder | Description | Tags |",
|
|
"|---|--------|--------|-----------|--------|-------------|------|",
|
|
]
|
|
for i, it in enumerate(items, 1):
|
|
d = it["description"].replace("\n", " ").replace("|", "\\|")
|
|
if len(d) > 160: d = d[:157] + "..."
|
|
tags_str = ", ".join(it.get("tags", [])[:6])
|
|
lines.append(
|
|
f"| {i} | {it['status']} | {it.get('domain','') or '-'} | "
|
|
f"{it.get('subdomain','') or '-'} | `{it['folder']}` | {d} | {tags_str} |"
|
|
)
|
|
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
|
|
|
|
Inspection:
|
|
status counts of active vs parked
|
|
list {active|parked|all} list skill folder names
|
|
categories / cats verb-prefix category counts (eg. performing, detecting)
|
|
domains frontmatter `domain:` distribution
|
|
subdomains frontmatter `subdomain:` distribution
|
|
tags frontmatter `tags:` distribution
|
|
|
|
Single skill:
|
|
enable <folder> enable single skill (copy parked → active)
|
|
disable <folder> disable single skill (remove from active; keep parked)
|
|
disable-all [-y|--yes] disable every active skill (asks for confirmation)
|
|
|
|
Bulk by axis:
|
|
enable-category <prefix> fzf multi-pick within a verb-prefix, then enable
|
|
disable-category <prefix> fzf multi-pick of ACTIVE skills with prefix, then disable
|
|
enable-domain <domain> bulk enable parked skills by frontmatter `domain:` (eg. cybersecurity)
|
|
disable-domain <domain> bulk disable from ACTIVE by `domain:`
|
|
enable-subdomain <s> bulk enable by `subdomain:` (eg. malware-analysis)
|
|
disable-subdomain <s>
|
|
enable-tag [--all] <tag>... bulk by `tags:`. default: union (any tag); --all: intersection
|
|
disable-tag [--all] <tag>...
|
|
|
|
Interactive / search:
|
|
pick fzf: choose category → multi-select → enable
|
|
disable-pick fzf: choose ACTIVE category → multi-select → disable
|
|
search [query] fzf fuzzy search across name+description (enable)
|
|
disable-search [query] fzf fuzzy search ACTIVE skills only (disable)
|
|
reindex rebuild INDEX.json / INDEX.md (extracts domain/subdomain/tags)
|
|
|
|
Notes:
|
|
- disable* commands never delete data — they remove the active copy and keep
|
|
(or restore) the parked copy. To re-enable: opc-skills enable <folder>.
|
|
- domain/subdomain/tag commands read INDEX.json — run reindex after manual changes.
|
|
|
|
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 "$@" ;;
|
|
domains) cmd_domains "$@" ;;
|
|
subdomains) cmd_subdomains "$@" ;;
|
|
tags) cmd_tags "$@" ;;
|
|
enable) cmd_enable "$@" ;;
|
|
disable) cmd_disable "$@" ;;
|
|
disable-all) cmd_disable_all "$@" ;;
|
|
enable-category|enable-cat) cmd_enable_category "$@" ;;
|
|
disable-category|disable-cat) cmd_disable_category "$@" ;;
|
|
enable-domain) cmd_enable_domain "$@" ;;
|
|
disable-domain) cmd_disable_domain "$@" ;;
|
|
enable-subdomain) cmd_enable_subdomain "$@" ;;
|
|
disable-subdomain) cmd_disable_subdomain "$@" ;;
|
|
enable-tag) cmd_enable_tag "$@" ;;
|
|
disable-tag) cmd_disable_tag "$@" ;;
|
|
pick) cmd_pick "$@" ;;
|
|
disable-pick) cmd_disable_pick "$@" ;;
|
|
search) cmd_search "$@" ;;
|
|
disable-search) cmd_disable_search "$@" ;;
|
|
reindex) cmd_reindex "$@" ;;
|
|
-h|--help|help) usage ;;
|
|
*) echo "unknown command: $cmd" >&2; usage; exit 2 ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|