Files
opc-skills/bin/opc-skills
salvacybersec 44daf2b969 feat: feynman target + interactive target picker + fzf preview fix
- 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.
2026-05-01 13:13:55 +03:00

917 lines
34 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "$@"