- Add feynman as third target (~/.feynman/agent/skills) alongside opencode
and claude. Default targets now: opencode,claude,feynman.
- Fix fzf {} preview shell-escape bug: {} in fzf is shell-escaped (wrapped
in single quotes), so "$PARKED/{}/SKILL.md" embedded literal quotes into
the path and broke preview. Switched to "$PARKED"/{}/SKILL.md (variable
quoted, {} unquoted) so bash concatenation yields a clean path.
- pick: after skill multi-select, prompt target picker (all / opencode /
claude / feynman, TAB for multi). ENTER on highlighted "all" = all three.
- disable-pick: rewritten to show union of actives across all 3 targets
with [oc,cl,fy] indicators, then target picker for which to disable from.
- cmd_status: 3-target output with all-three intersection counter.
- reindex: tracks per-target active arrays for all 3.
- New env vars: OPC_FEYNMAN_ACTIVE.
917 lines
34 KiB
Bash
Executable File
917 lines
34 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# opc-skills — multi-target skill enable/disable manager (opencode + claude + feynman)
|
||
#
|
||
# Parked (single source-of-truth):
|
||
# ~/Documents/personas/skills-archive (default; symlinked from old location)
|
||
#
|
||
# Active targets (where enable copies to):
|
||
# opencode → ~/.config/opencode/skills
|
||
# claude → ~/.claude/skills
|
||
# feynman → ~/.feynman/agent/skills
|
||
#
|
||
# Default targets when --target is omitted: opencode,claude,feynman (set OPC_TARGETS to override)
|
||
|
||
set -euo pipefail
|
||
|
||
PARKED="${OPC_PARKED:-$HOME/Documents/personas/skills-archive}"
|
||
ACTIVE_OPENCODE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}"
|
||
ACTIVE_CLAUDE="${OPC_CLAUDE_ACTIVE:-$HOME/.claude/skills}"
|
||
ACTIVE_FEYNMAN="${OPC_FEYNMAN_ACTIVE:-$HOME/.feynman/agent/skills}"
|
||
INDEX_JSON="$PARKED/INDEX.json"
|
||
INDEX_MD="$PARKED/INDEX.md"
|
||
|
||
# default target set; can be overridden via OPC_TARGETS or --target flag per command
|
||
DEFAULT_TARGETS="${OPC_TARGETS:-opencode,claude,feynman}"
|
||
|
||
# resolve a target name to its active dir
|
||
target_dir() {
|
||
case "$1" in
|
||
opencode) printf '%s\n' "$ACTIVE_OPENCODE" ;;
|
||
claude) printf '%s\n' "$ACTIVE_CLAUDE" ;;
|
||
feynman) printf '%s\n' "$ACTIVE_FEYNMAN" ;;
|
||
*) echo "unknown target: $1 (valid: opencode, claude, feynman)" >&2; return 1 ;;
|
||
esac
|
||
}
|
||
|
||
# expand "both"/"all"/"opencode,claude,feynman" → ordered unique list of valid targets
|
||
resolve_targets() {
|
||
local raw="${1:-$DEFAULT_TARGETS}"
|
||
case "$raw" in
|
||
all) raw="opencode,claude,feynman" ;;
|
||
both) raw="opencode,claude" ;;
|
||
esac
|
||
printf '%s\n' "$raw" | tr ',' '\n' | awk 'NF' | awk '!seen[$0]++'
|
||
}
|
||
|
||
# parse --target X (mutates argv via global REMAINING_ARGS array, prints chosen targets)
|
||
# usage: parse_target_flag "$@"; set -- "${REMAINING_ARGS[@]}"; echo "$PARSED_TARGETS"
|
||
parse_target_flag() {
|
||
REMAINING_ARGS=()
|
||
PARSED_TARGETS=""
|
||
local seen_target=""
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--target=*) seen_target="${1#--target=}"; shift ;;
|
||
--target|-t) seen_target="${2:-}"; shift 2 ;;
|
||
*) REMAINING_ARGS+=("$1"); shift ;;
|
||
esac
|
||
done
|
||
if [ -n "$seen_target" ]; then
|
||
PARSED_TARGETS=$(resolve_targets "$seen_target" | paste -sd ',')
|
||
else
|
||
PARSED_TARGETS=$(resolve_targets | paste -sd ',')
|
||
fi
|
||
}
|
||
|
||
# active set per target (helpers used by status/list)
|
||
active_dirs_for_target() {
|
||
local t="$1"
|
||
local base; base=$(target_dir "$t") || return 1
|
||
[ -d "$base" ] || return 0
|
||
find "$base" -mindepth 1 -maxdepth 1 -type d \
|
||
! -name bin ! -name '.*' -printf '%f\n' | sort
|
||
}
|
||
|
||
require_dirs() {
|
||
[ -d "$PARKED" ] || { echo "parked dir missing: $PARKED" >&2; exit 1; }
|
||
mkdir -p "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" "$ACTIVE_FEYNMAN"
|
||
}
|
||
|
||
# legacy alias — many commands still use $ACTIVE for fzf preview / category / search
|
||
# (these are single-target operations; default to opencode target which most users care about)
|
||
ACTIVE="$ACTIVE_OPENCODE"
|
||
|
||
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 parked; parked=$(skill_dirs_in "$PARKED" | wc -l)
|
||
printf "parked : %5d (%s)\n" "$parked" "$PARKED"
|
||
echo
|
||
printf "%-10s %5s %s\n" "target" "count" "dir"
|
||
local t n
|
||
for t in opencode claude feynman; do
|
||
n=$(active_dirs_for_target "$t" | wc -l)
|
||
printf "%-10s %5d %s\n" "$t" "$n" "$(target_dir "$t")"
|
||
done
|
||
# union (any target) vs all-three intersection
|
||
local in_oc in_cl in_fy all3 any
|
||
in_oc=$(active_dirs_for_target opencode)
|
||
in_cl=$(active_dirs_for_target claude)
|
||
in_fy=$(active_dirs_for_target feynman)
|
||
all3=$(comm -12 \
|
||
<(comm -12 <(echo "$in_oc" | sort) <(echo "$in_cl" | sort)) \
|
||
<(echo "$in_fy" | sort) | grep -c . || true)
|
||
any=$( { echo "$in_oc"; echo "$in_cl"; echo "$in_fy"; } | sort -u | grep -c . || true)
|
||
echo
|
||
printf "any target : %5d\n" "$any"
|
||
printf "all three : %5d\n" "$all3"
|
||
}
|
||
|
||
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) | .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 ((.active // []) | length > 0)) | .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) | .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 ((.active // []) | length > 0)) | .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([.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((.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(((.active // []) | length > 0) 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(((.active // []) | length > 0) 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 — per-target (active set is JSON array)
|
||
update_index_active_set() {
|
||
local folder="$1" target="$2" op="$3" # op = add|remove
|
||
[ -f "$INDEX_JSON" ] || return 0
|
||
command -v jq >/dev/null || return 0
|
||
local tmp; tmp=$(mktemp)
|
||
jq --arg f "$folder" --arg t "$target" --arg op "$op" '
|
||
map(if .folder == $f then
|
||
.active = ((.active // []) | (if $op=="add" then (. + [$t] | unique)
|
||
else (. - [$t]) end))
|
||
| .status = (if (.active|length) > 0 then "active" else "parked" end)
|
||
else . end)
|
||
' "$INDEX_JSON" > "$tmp" && mv "$tmp" "$INDEX_JSON"
|
||
}
|
||
|
||
# sync shared refs (e.g. _platform-mapping.md) into all configured target dirs
|
||
sync_shared_refs_to_targets() {
|
||
local f t base
|
||
for t in opencode claude feynman; do
|
||
base=$(target_dir "$t")
|
||
for f in _platform-mapping.md; do
|
||
if [ -f "$PARKED/$f" ] && [ ! -f "$base/$f" ]; then
|
||
cp "$PARKED/$f" "$base/$f"
|
||
fi
|
||
done
|
||
done
|
||
}
|
||
|
||
# enable one skill into one or more targets
|
||
# usage: cmd_enable [--target T] <folder>
|
||
cmd_enable() {
|
||
parse_target_flag "$@"
|
||
set -- "${REMAINING_ARGS[@]}"
|
||
require_dirs
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { echo "usage: opc-skills enable [--target opencode|claude|both] <folder>" >&2; exit 2; }
|
||
local src="$PARKED/$name"
|
||
[ -d "$src" ] || { echo "not parked: $name" >&2; exit 1; }
|
||
local t base dst
|
||
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||
for t in "${tgts[@]}"; do
|
||
base=$(target_dir "$t") || continue
|
||
dst="$base/$name"
|
||
if [ -d "$dst" ]; then
|
||
echo "[$t] already active: $name"
|
||
else
|
||
cp -r "$src" "$dst"
|
||
update_index_active_set "$name" "$t" "add"
|
||
echo "[$t] enabled: $name"
|
||
fi
|
||
done
|
||
sync_shared_refs_to_targets
|
||
}
|
||
|
||
# disable one skill from one or more targets
|
||
cmd_disable() {
|
||
parse_target_flag "$@"
|
||
set -- "${REMAINING_ARGS[@]}"
|
||
require_dirs
|
||
local name="${1:-}"
|
||
[ -n "$name" ] || { echo "usage: opc-skills disable [--target opencode|claude|both] <folder>" >&2; exit 2; }
|
||
local t base src
|
||
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||
for t in "${tgts[@]}"; do
|
||
base=$(target_dir "$t") || continue
|
||
src="$base/$name"
|
||
if [ ! -d "$src" ]; then
|
||
echo "[$t] not active: $name"
|
||
continue
|
||
fi
|
||
rm -rf "$src"
|
||
update_index_active_set "$name" "$t" "remove"
|
||
echo "[$t] disabled: $name"
|
||
done
|
||
}
|
||
|
||
# disable all active skills across selected targets
|
||
# usage: cmd_disable_all [--target ...] [-y|--yes]
|
||
cmd_disable_all() {
|
||
parse_target_flag "$@"
|
||
set -- "${REMAINING_ARGS[@]}"
|
||
require_dirs
|
||
local force=0
|
||
if [ "${1:-}" = "-y" ] || [ "${1:-}" = "--yes" ]; then
|
||
force=1
|
||
fi
|
||
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||
# Union of active skills across selected targets
|
||
local -a actives
|
||
mapfile -t actives < <(
|
||
for t in "${tgts[@]}"; do active_dirs_for_target "$t"; done | sort -u
|
||
)
|
||
[ "${#actives[@]}" -gt 0 ] || { echo "no active skills in targets: ${tgts[*]}"; exit 0; }
|
||
echo "Will disable ${#actives[@]} active skill(s) across targets [${tgts[*]}]:"
|
||
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 t base
|
||
for n in "${actives[@]}"; do
|
||
for t in "${tgts[@]}"; do
|
||
base=$(target_dir "$t") || continue
|
||
[ -d "$base/$n" ] || continue
|
||
rm -rf "$base/$n"
|
||
update_index_active_set "$n" "$t" "remove"
|
||
done
|
||
done
|
||
echo "done."
|
||
}
|
||
|
||
# enable every parked skill into selected target(s) — symmetric to disable-all
|
||
# usage: cmd_enable_all [--target T] [-y|--yes]
|
||
# Idempotent: skips skills already active in a target.
|
||
cmd_enable_all() {
|
||
parse_target_flag "$@"
|
||
set -- "${REMAINING_ARGS[@]}"
|
||
require_dirs
|
||
local force=0
|
||
if [ "${1:-}" = "-y" ] || [ "${1:-}" = "--yes" ]; then
|
||
force=1
|
||
fi
|
||
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||
# For each target, list parked skills NOT already active there
|
||
local -A pending=()
|
||
local -a parked_list
|
||
mapfile -t parked_list < <(skill_dirs_in "$PARKED")
|
||
local total=0 t
|
||
for t in "${tgts[@]}"; do
|
||
local base; base=$(target_dir "$t")
|
||
local n
|
||
for n in "${parked_list[@]}"; do
|
||
[ -z "$n" ] && continue
|
||
if [ ! -d "$base/$n" ]; then
|
||
pending["$t:$n"]=1
|
||
total=$((total+1))
|
||
fi
|
||
done
|
||
done
|
||
[ "$total" -gt 0 ] || { echo "all parked skills already active in [${tgts[*]}]"; exit 0; }
|
||
echo "Will enable $total (target:skill) entries from parked → active in [${tgts[*]}]:"
|
||
echo "(${#parked_list[@]} parked × ${#tgts[@]} targets, minus already-active)"
|
||
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 key tn
|
||
local enabled=0
|
||
for key in "${!pending[@]}"; do
|
||
t="${key%%:*}"; tn="${key#*:}"
|
||
base=$(target_dir "$t")
|
||
if [ ! -d "$PARKED/$tn" ] || [ -d "$base/$tn" ]; then continue; fi
|
||
cp -r "$PARKED/$tn" "$base/$tn"
|
||
update_index_active_set "$tn" "$t" "add"
|
||
enabled=$((enabled+1))
|
||
done
|
||
sync_shared_refs_to_targets
|
||
echo "enabled $enabled skill(s)."
|
||
}
|
||
|
||
# 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 target picker — returns comma-separated targets on stdout, exit 1 on cancel.
|
||
# Default highlight = "all" (= opencode,claude,feynman). TAB to multi-select specific ones.
|
||
pick_targets() {
|
||
local action="${1:-enable}"
|
||
ensure_fzf
|
||
local sel
|
||
# Show counts so user can see current state per target
|
||
local oc cl fy
|
||
oc=$(active_dirs_for_target opencode | wc -l)
|
||
cl=$(active_dirs_for_target claude | wc -l)
|
||
fy=$(active_dirs_for_target feynman | wc -l)
|
||
sel=$(printf '%s\n' \
|
||
"all → opencode + claude + feynman" \
|
||
"opencode ($oc active)" \
|
||
"claude ($cl active)" \
|
||
"feynman ($fy active)" \
|
||
| fzf --multi --height=30% \
|
||
--prompt="$action target > " \
|
||
--header="TAB: multi-select | ENTER: confirm (default highlighted = all)" \
|
||
| awk '{print $1}')
|
||
[ -n "$sel" ] || return 1
|
||
if grep -qx all <<< "$sel"; then
|
||
echo "opencode,claude,feynman"
|
||
else
|
||
echo "$sel" | paste -sd ','
|
||
fi
|
||
}
|
||
|
||
# interactive disable-pick: union of actives across all targets → multi-select → pick targets to disable from
|
||
cmd_disable_pick() {
|
||
require_dirs
|
||
ensure_fzf
|
||
# Build "skill\t[targets]" map: which targets each skill is currently active in
|
||
local tmp
|
||
tmp=$(mktemp)
|
||
{
|
||
for t in opencode claude feynman; do
|
||
while IFS= read -r n; do
|
||
[ -z "$n" ] && continue
|
||
printf '%s\t%s\n' "$n" "$t"
|
||
done < <(active_dirs_for_target "$t")
|
||
done
|
||
} | awk -F'\t' '
|
||
{ skills[$1] = (skills[$1] ? skills[$1]"," : "") $2 }
|
||
END { for (s in skills) printf "%s\t[%s]\n", s, skills[s] }
|
||
' | sort > "$tmp"
|
||
[ -s "$tmp" ] || { rm -f "$tmp"; echo "no active skills across any target"; exit 0; }
|
||
|
||
# Active categories from the union
|
||
local cats
|
||
cats=$(awk -F'\t' '{print $1}' "$tmp" | awk -F'-' '{print $1}' | sort | uniq -c | sort -rn)
|
||
local category
|
||
category=$(printf '%s\n' "$cats" | fzf --prompt="active-category > " --height=50% \
|
||
--header="Pick category from ACTIVE skills (any target)" \
|
||
| awk '{print $2}')
|
||
[ -n "$category" ] || { rm -f "$tmp"; echo "cancelled"; exit 0; }
|
||
|
||
# Show "skill [targets]" lines, multi-select
|
||
local selection
|
||
selection=$(awk -F'\t' -v p="$category" '$1 ~ "^"p"(-|$)"' "$tmp" \
|
||
| fzf --multi --height=80% \
|
||
--prompt="disable $category > " \
|
||
--header="TAB: toggle | ENTER: pick target(s) to disable from" \
|
||
--delimiter='\t' --with-nth=1,2 \
|
||
--preview="sed -n '1,40p' \"$PARKED\"/{1}/SKILL.md 2>/dev/null || echo '(not parked — read from active fallback)'" \
|
||
--preview-window=right:60%:wrap)
|
||
rm -f "$tmp"
|
||
[ -n "$selection" ] || { echo "cancelled"; exit 0; }
|
||
|
||
# Pick target(s) to disable from
|
||
local targets
|
||
targets=$(pick_targets "disable") || { echo "cancelled (no target)"; exit 0; }
|
||
|
||
local n
|
||
while IFS=$'\t' read -r n _; do
|
||
[ -z "$n" ] && continue
|
||
cmd_disable --target "$targets" "$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((.active // []) | length > 0) | "\(.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 → pick target(s)
|
||
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: confirm → pick target" \
|
||
--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; }
|
||
|
||
# Pick target(s) for enable destination
|
||
local targets
|
||
targets=$(pick_targets "enable") || { echo "cancelled (no target)"; exit 0; }
|
||
|
||
local n
|
||
while IFS= read -r n; do
|
||
[ -z "$n" ] && continue
|
||
cmd_enable --target "$targets" "$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 + per-target active dirs, regenerate INDEX.json / INDEX.md
|
||
cmd_reindex() {
|
||
require_dirs
|
||
python3 - "$PARKED" "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" "$ACTIVE_FEYNMAN" "$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_opencode = Path(sys.argv[2])
|
||
active_claude = Path(sys.argv[3])
|
||
active_feynman = Path(sys.argv[4])
|
||
out_json = Path(sys.argv[5])
|
||
out_md = Path(sys.argv[6])
|
||
|
||
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_parked(base: Path):
|
||
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)
|
||
rec["active"] = [] # per-target active list (filled below)
|
||
rec["status"] = "parked"
|
||
seen[d.name] = rec
|
||
|
||
def scan_target(base: Path, target_name: 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 = seen.get(d.name)
|
||
if rec is None:
|
||
rec = read_skill(d)
|
||
rec["active"] = []
|
||
seen[d.name] = rec
|
||
if target_name not in rec["active"]:
|
||
rec["active"].append(target_name)
|
||
rec["status"] = "active"
|
||
|
||
scan_parked(parked)
|
||
scan_target(active_opencode, "opencode")
|
||
scan_target(active_claude, "claude")
|
||
scan_target(active_feynman, "feynman")
|
||
|
||
items = sorted(seen.values(), key=lambda x: x["folder"])
|
||
out_json.write_text(json.dumps(items, ensure_ascii=False, indent=2))
|
||
|
||
parked_n = sum(1 for i in items if not i["active"])
|
||
in_oc = sum(1 for i in items if "opencode" in i["active"])
|
||
in_cl = sum(1 for i in items if "claude" in i["active"])
|
||
in_fy = sum(1 for i in items if "feynman" in i["active"])
|
||
in_all3 = sum(1 for i in items if {"opencode","claude","feynman"}.issubset(set(i["active"])))
|
||
in_any = sum(1 for i in items if i["active"])
|
||
|
||
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 + Claude + Feynman Skills — Index",
|
||
"",
|
||
f"**{len(items)} unique skills** — parked: {parked_n}, active any: {in_any}, all three: {in_all3}.",
|
||
f"Per-target — opencode: {in_oc}, claude: {in_cl}, feynman: {in_fy}.",
|
||
f"Top domains — {top_doms}.",
|
||
f"Top subdomains — {top_subs}.",
|
||
f"Top tags — {top_tags}.",
|
||
"",
|
||
f"Parked: `{parked}`",
|
||
f"opencode active: `{active_opencode}`",
|
||
f"claude active: `{active_claude}`",
|
||
f"feynman active: `{active_feynman}`",
|
||
"",
|
||
"See `README.md` for the `opc-skills` CLI.",
|
||
"",
|
||
"| # | Active | 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])
|
||
active_str = ",".join(it.get("active", [])) or "-"
|
||
lines.append(
|
||
f"| {i} | {active_str} | {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 (parked={parked_n}, opencode={in_oc}, claude={in_cl}, feynman={in_fy}, all3={in_all3})")
|
||
PY
|
||
}
|
||
|
||
usage() {
|
||
cat <<'USG'
|
||
opc-skills — multi-target skill manager (opencode + claude + feynman)
|
||
|
||
Targets: opencode, claude, feynman ("all"=all three, "both"=opencode+claude legacy)
|
||
Default = opencode,claude,feynman. Override per-call with --target X / -t X.
|
||
|
||
Inspection:
|
||
status per-target counts + parked
|
||
list {active|parked|all} list skill folder names (active = opencode set)
|
||
categories / cats verb-prefix category counts (eg. performing, detecting)
|
||
domains frontmatter `domain:` distribution
|
||
subdomains frontmatter `subdomain:` distribution
|
||
tags frontmatter `tags:` distribution
|
||
|
||
Single skill:
|
||
enable [--target T] <folder> copy parked → active in selected targets
|
||
disable [--target T] <folder> remove from active in selected targets (parked kept)
|
||
enable-all [--target T] [-y|--yes] enable every parked skill into selected targets
|
||
(idempotent — skips already-active; useful as restore-from-archive)
|
||
disable-all [--target T] [-y|--yes] disable every active skill across targets
|
||
|
||
Bulk by axis (default: opencode+claude+feynman):
|
||
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 (per-target active sets)
|
||
|
||
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 — incremental updates happen
|
||
automatically on enable/disable.
|
||
- Single source-of-truth lives in personas repo (~/Documents/personas/skills-archive).
|
||
|
||
Environment:
|
||
OPC_PARKED override parked dir (default: ~/Documents/personas/skills-archive)
|
||
OPC_ACTIVE opencode active dir (default: ~/.config/opencode/skills)
|
||
OPC_CLAUDE_ACTIVE claude active dir (default: ~/.claude/skills)
|
||
OPC_FEYNMAN_ACTIVE feynman active dir (default: ~/.feynman/agent/skills)
|
||
OPC_TARGETS comma-separated default targets when --target omitted
|
||
(default: opencode,claude,feynman)
|
||
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 "$@" ;;
|
||
enable-all) cmd_enable_all "$@" ;;
|
||
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 "$@"
|