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.
This commit is contained in:
salvacybersec
2026-05-01 13:13:55 +03:00
parent f53f9320a8
commit 44daf2b969
2 changed files with 154 additions and 66 deletions

View File

@@ -1,10 +1,10 @@
# opc-skills # opc-skills
**Multi-target** skill manager for **opencode** + **Claude Code**. Single source-of-truth (parked archive in the personas repo), per-target lazy activation. Default operations target both platforms. **Multi-target** skill manager for **opencode** + **Claude Code** + **Feynman**. Single source-of-truth (parked archive in the personas repo), per-target lazy activation. Default operations target all three platforms; an interactive target-picker prompts when picking via fzf.
## Why ## Why
opencode (≥1.14) auto-scans `~/.claude/skills/` as well as its own `~/.config/opencode/skills/` — so a 1000-skill Claude Code archive ends up bloating the opencode prompt. opc-skills inverts this: keep both target dirs *empty by default*, copy individual skills into them on demand. opencode (≥1.14) auto-scans `~/.claude/skills/` as well as its own `~/.config/opencode/skills/` — so a 1000-skill Claude Code archive ends up bloating the opencode prompt. Feynman has its own `~/.feynman/agent/skills/`. opc-skills inverts this: keep all three target dirs *empty by default*, copy individual skills into them on demand.
For full agent / skill / persona generation across multiple AI platforms, see the upstream [`personas`](https://gitea.taygun.net.tr/salvacybersec/personas) repo. For full agent / skill / persona generation across multiple AI platforms, see the upstream [`personas`](https://gitea.taygun.net.tr/salvacybersec/personas) repo.
@@ -29,6 +29,9 @@ Ensure `~/.local/bin` is on your `PATH`.
~/.claude/skills/ # currently-enabled (claude target) ~/.claude/skills/ # currently-enabled (claude target)
<skill-name>/SKILL.md <skill-name>/SKILL.md
~/.feynman/agent/skills/ # currently-enabled (feynman target)
<skill-name>/SKILL.md
``` ```
(`~/Documents/opencode-skills-parked` is symlinked to `personas/skills-archive` for backward-compat.) (`~/Documents/opencode-skills-parked` is symlinked to `personas/skills-archive` for backward-compat.)
@@ -37,7 +40,8 @@ Override via env:
- `OPC_PARKED` — parked catalog root (default `~/Documents/personas/skills-archive`) - `OPC_PARKED` — parked catalog root (default `~/Documents/personas/skills-archive`)
- `OPC_ACTIVE` — opencode active dir (default `~/.config/opencode/skills`) - `OPC_ACTIVE` — opencode active dir (default `~/.config/opencode/skills`)
- `OPC_CLAUDE_ACTIVE` — claude active dir (default `~/.claude/skills`) - `OPC_CLAUDE_ACTIVE` — claude active dir (default `~/.claude/skills`)
- `OPC_TARGETS` — comma-separated default targets (default `opencode,claude`) - `OPC_FEYNMAN_ACTIVE` — feynman active dir (default `~/.feynman/agent/skills`)
- `OPC_TARGETS` — comma-separated default targets (default `opencode,claude,feynman`)
## Targets ## Targets
@@ -47,16 +51,19 @@ Override via env:
|---|---| |---|---|
| `--target opencode` | only `~/.config/opencode/skills/` | | `--target opencode` | only `~/.config/opencode/skills/` |
| `--target claude` | only `~/.claude/skills/` | | `--target claude` | only `~/.claude/skills/` |
| `--target both` (or `all`) | both — also the default | | `--target feynman` | only `~/.feynman/agent/skills/` |
| `--target all` | all three |
| `--target both` | legacy `opencode,claude` (no feynman) |
| `--target opencode,feynman` | comma-separated subset |
When omitted, the default is taken from `OPC_TARGETS` (default `opencode,claude`). When omitted, the default is taken from `OPC_TARGETS` (default `opencode,claude,feynman`).
## Commands ## Commands
### Inspection ### Inspection
``` ```
opc-skills status # parked + per-target active counts opc-skills status # parked + per-target active counts (3 targets)
opc-skills list {active|parked|all} # skill folder names opc-skills list {active|parked|all} # skill folder names
opc-skills categories # verb-prefix counts (performing, detecting, ...) opc-skills categories # verb-prefix counts (performing, detecting, ...)
opc-skills domains # frontmatter `domain:` distribution opc-skills domains # frontmatter `domain:` distribution
@@ -88,27 +95,42 @@ opc-skills disable-tag [--all] <tag>...
### Interactive / search ### Interactive / search
``` ```
opc-skills pick # fzf: category → multi-select → enable opc-skills pick # fzf: category → multi-select skills → target picker → enable
opc-skills disable-pick # fzf: ACTIVE category → multi-select → disable opc-skills disable-pick # fzf: union-of-actives → category → multi-select → target picker → disable
opc-skills search [query] # fzf fuzzy over name+description opc-skills search [query] # fzf fuzzy over name+description (enables on selection)
opc-skills disable-search [query] # fzf over ACTIVE only opc-skills disable-search [query] # fzf over ACTIVE only (disables on selection)
opc-skills reindex # rebuild INDEX.json / INDEX.md opc-skills reindex # rebuild INDEX.json / INDEX.md
``` ```
#### `pick` flow
1. Choose a verb-prefix category (`performing`, `detecting`, …)
2. fzf shows skills in that category — `TAB` toggles, `ENTER` confirms
3. **Target picker** appears: `all` / `opencode` / `claude` / `feynman``TAB` for multi-select; `ENTER` on highlighted `all` (default) sends to all three
4. Selected skills are enabled in the chosen target(s)
#### `disable-pick` flow
Now multi-target aware: lists the **union of all active skills across the three targets**, with each entry annotated `[oc,cl,fy]` showing where it's currently active. After multi-select, the same target picker decides which target(s) to remove from.
`fzf` is required for the interactive commands; `jq` for the bulk-by-axis commands; `python3` (with optional `PyYAML`) for `reindex`. `fzf` is required for the interactive commands; `jq` for the bulk-by-axis commands; `python3` (with optional `PyYAML`) for `reindex`.
### INDEX schema ### INDEX schema
`reindex` parses each `SKILL.md` frontmatter and emits records with: `folder`, `name`, `description`, `domain`, `subdomain`, `tags[]`, `status`. `enable` / `disable` perform an incremental status update on `INDEX.json` so `enable-domain` / `disable-tag` etc. stay accurate without a full reindex. `reindex` parses each `SKILL.md` frontmatter and emits records with: `folder`, `name`, `description`, `domain`, `subdomain`, `tags[]`, `active[]` (per-target list — `opencode`/`claude`/`feynman`), `status`. `enable` / `disable` perform an incremental status update on `INDEX.json` so `enable-domain` / `disable-tag` etc. stay accurate without a full reindex.
## Shared reference files ## Shared reference files
Some skills reference sibling files via `../<name>.md` (e.g. the 20 Feynman research skills use `../_platform-mapping.md` for cross-platform tool mapping). `opc-skills enable` auto-syncs any such files from PARKED → ACTIVE so the relative references keep resolving. Some skills reference sibling files via `../<name>.md` (e.g. the 20 Feynman research skills use `../_platform-mapping.md` for cross-platform tool mapping). `opc-skills enable` auto-syncs any such files from PARKED → ACTIVE in **all three targets** so the relative references keep resolving.
Currently synced: Currently synced:
- `_platform-mapping.md` — Feynman-skills cross-platform subagent/scheduling/persistence mapping - `_platform-mapping.md` — Feynman-skills cross-platform subagent/scheduling/persistence mapping
If you add new shared-reference files at the PARKED root, extend `sync_shared_refs()` in `bin/opc-skills`. If you add new shared-reference files at the PARKED root, extend `sync_shared_refs_to_targets()` in `bin/opc-skills`.
## Implementation notes
- `fzf` placeholder `{}` is shell-escaped automatically — preview commands use `"$PARKED"/{}/SKILL.md` (variable quoted, `{}` unquoted) so bash concatenation produces a clean path. Wrapping `{}` in extra quotes (`"$PARKED/{}/SKILL.md"`) embeds fzf's escape literals into the path and breaks preview.
## Known interaction ## Known interaction

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# opc-skills — multi-target skill enable/disable manager (opencode + claude) # opc-skills — multi-target skill enable/disable manager (opencode + claude + feynman)
# #
# Parked (single source-of-truth): # Parked (single source-of-truth):
# ~/Documents/personas/skills-archive (default; symlinked from old location) # ~/Documents/personas/skills-archive (default; symlinked from old location)
@@ -7,34 +7,38 @@
# Active targets (where enable copies to): # Active targets (where enable copies to):
# opencode → ~/.config/opencode/skills # opencode → ~/.config/opencode/skills
# claude → ~/.claude/skills # claude → ~/.claude/skills
# feynman → ~/.feynman/agent/skills
# #
# Default targets when --target is omitted: opencode,claude (set OPC_TARGETS to override) # Default targets when --target is omitted: opencode,claude,feynman (set OPC_TARGETS to override)
set -euo pipefail set -euo pipefail
PARKED="${OPC_PARKED:-$HOME/Documents/personas/skills-archive}" PARKED="${OPC_PARKED:-$HOME/Documents/personas/skills-archive}"
ACTIVE_OPENCODE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}" ACTIVE_OPENCODE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}"
ACTIVE_CLAUDE="${OPC_CLAUDE_ACTIVE:-$HOME/.claude/skills}" ACTIVE_CLAUDE="${OPC_CLAUDE_ACTIVE:-$HOME/.claude/skills}"
ACTIVE_FEYNMAN="${OPC_FEYNMAN_ACTIVE:-$HOME/.feynman/agent/skills}"
INDEX_JSON="$PARKED/INDEX.json" INDEX_JSON="$PARKED/INDEX.json"
INDEX_MD="$PARKED/INDEX.md" INDEX_MD="$PARKED/INDEX.md"
# default target set; can be overridden via OPC_TARGETS or --target flag per command # default target set; can be overridden via OPC_TARGETS or --target flag per command
DEFAULT_TARGETS="${OPC_TARGETS:-opencode,claude}" DEFAULT_TARGETS="${OPC_TARGETS:-opencode,claude,feynman}"
# resolve a target name to its active dir # resolve a target name to its active dir
target_dir() { target_dir() {
case "$1" in case "$1" in
opencode) printf '%s\n' "$ACTIVE_OPENCODE" ;; opencode) printf '%s\n' "$ACTIVE_OPENCODE" ;;
claude) printf '%s\n' "$ACTIVE_CLAUDE" ;; claude) printf '%s\n' "$ACTIVE_CLAUDE" ;;
*) echo "unknown target: $1 (valid: opencode, claude)" >&2; return 1 ;; feynman) printf '%s\n' "$ACTIVE_FEYNMAN" ;;
*) echo "unknown target: $1 (valid: opencode, claude, feynman)" >&2; return 1 ;;
esac esac
} }
# expand "both"/"all"/"opencode,claude" → ordered unique list of valid targets # expand "both"/"all"/"opencode,claude,feynman" → ordered unique list of valid targets
resolve_targets() { resolve_targets() {
local raw="${1:-$DEFAULT_TARGETS}" local raw="${1:-$DEFAULT_TARGETS}"
case "$raw" in case "$raw" in
both|all) raw="opencode,claude" ;; all) raw="opencode,claude,feynman" ;;
both) raw="opencode,claude" ;;
esac esac
printf '%s\n' "$raw" | tr ',' '\n' | awk 'NF' | awk '!seen[$0]++' printf '%s\n' "$raw" | tr ',' '\n' | awk 'NF' | awk '!seen[$0]++'
} }
@@ -70,7 +74,7 @@ active_dirs_for_target() {
require_dirs() { require_dirs() {
[ -d "$PARKED" ] || { echo "parked dir missing: $PARKED" >&2; exit 1; } [ -d "$PARKED" ] || { echo "parked dir missing: $PARKED" >&2; exit 1; }
mkdir -p "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" mkdir -p "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" "$ACTIVE_FEYNMAN"
} }
# legacy alias — many commands still use $ACTIVE for fzf preview / category / search # legacy alias — many commands still use $ACTIVE for fzf preview / category / search
@@ -92,19 +96,22 @@ cmd_status() {
echo echo
printf "%-10s %5s %s\n" "target" "count" "dir" printf "%-10s %5s %s\n" "target" "count" "dir"
local t n local t n
for t in opencode claude; do for t in opencode claude feynman; do
n=$(active_dirs_for_target "$t" | wc -l) n=$(active_dirs_for_target "$t" | wc -l)
printf "%-10s %5d %s\n" "$t" "$n" "$(target_dir "$t")" printf "%-10s %5d %s\n" "$t" "$n" "$(target_dir "$t")"
done done
# union (any target) vs both (all targets) # union (any target) vs all-three intersection
local in_oc in_cl both any local in_oc in_cl in_fy all3 any
in_oc=$(active_dirs_for_target opencode) in_oc=$(active_dirs_for_target opencode)
in_cl=$(active_dirs_for_target claude) in_cl=$(active_dirs_for_target claude)
both=$(comm -12 <(echo "$in_oc" | sort) <(echo "$in_cl" | sort) | grep -c .) in_fy=$(active_dirs_for_target feynman)
any=$( { echo "$in_oc"; echo "$in_cl"; } | sort -u | grep -c .) 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 echo
printf "any target : %5d\n" "$any" printf "any target : %5d\n" "$any"
printf "both : %5d\n" "$both" printf "all three : %5d\n" "$all3"
} }
cmd_list() { cmd_list() {
@@ -301,7 +308,7 @@ update_index_active_set() {
# sync shared refs (e.g. _platform-mapping.md) into all configured target dirs # sync shared refs (e.g. _platform-mapping.md) into all configured target dirs
sync_shared_refs_to_targets() { sync_shared_refs_to_targets() {
local f t base local f t base
for t in opencode claude; do for t in opencode claude feynman; do
base=$(target_dir "$t") base=$(target_dir "$t")
for f in _platform-mapping.md; do for f in _platform-mapping.md; do
if [ -f "$PARKED/$f" ] && [ ! -f "$base/$f" ]; then if [ -f "$PARKED/$f" ] && [ ! -f "$base/$f" ]; then
@@ -467,34 +474,83 @@ cmd_disable_category() {
done <<< "$selection" done <<< "$selection"
} }
# interactive pick (active only): category → multi-select skills to disable # 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() { cmd_disable_pick() {
require_dirs require_dirs
ensure_fzf ensure_fzf
local active_count # Build "skill\t[targets]" map: which targets each skill is currently active in
active_count=$(skill_dirs_in "$ACTIVE" | wc -l) local tmp
[ "$active_count" -gt 0 ] || { echo "no active skills"; exit 0; } tmp=$(mktemp)
# Active categories (by prefix) {
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 local cats
cats=$(skill_dirs_in "$ACTIVE" | awk -F'-' '{print $1}' | sort | uniq -c | sort -rn) cats=$(awk -F'\t' '{print $1}' "$tmp" | awk -F'-' '{print $1}' | sort | uniq -c | sort -rn)
local category local category
category=$(printf '%s\n' "$cats" | fzf --prompt="active-category > " --height=50% \ category=$(printf '%s\n' "$cats" | fzf --prompt="active-category > " --height=50% \
--header="Pick a category of ACTIVE skills to disable" \ --header="Pick category from ACTIVE skills (any target)" \
| awk '{print $2}') | awk '{print $2}')
[ -n "$category" ] || { echo "cancelled"; exit 0; } [ -n "$category" ] || { rm -f "$tmp"; echo "cancelled"; exit 0; }
# Show "skill [targets]" lines, multi-select
local selection local selection
selection=$(skill_dirs_in "$ACTIVE" \ selection=$(awk -F'\t' -v p="$category" '$1 ~ "^"p"(-|$)"' "$tmp" \
| awk -v p="$category" '$0 ~ "^" p "(-|$)"' \
| fzf --multi --height=80% \ | fzf --multi --height=80% \
--prompt="disable $category > " \ --prompt="disable $category > " \
--header="TAB: toggle | ENTER: disable selected" \ --header="TAB: toggle | ENTER: pick target(s) to disable from" \
--preview="sed -n '1,40p' \"$ACTIVE/{}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \ --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) --preview-window=right:60%:wrap)
rm -f "$tmp"
[ -n "$selection" ] || { echo "cancelled"; exit 0; } [ -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 local n
while IFS= read -r n; do while IFS=$'\t' read -r n _; do
[ -z "$n" ] && continue [ -z "$n" ] && continue
cmd_disable "$n" || true cmd_disable --target "$targets" "$n" || true
done <<< "$selection" done <<< "$selection"
} }
@@ -516,14 +572,14 @@ cmd_disable_search() {
--delimiter='\t' --with-nth=1,2 \ --delimiter='\t' --with-nth=1,2 \
--prompt="disable-search > " \ --prompt="disable-search > " \
--header="TAB: toggle | ENTER: disable selected" \ --header="TAB: toggle | ENTER: disable selected" \
--preview="sed -n '1,40p' \"$ACTIVE/{1}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \ --preview="sed -n '1,40p' \"$ACTIVE\"/{1}/SKILL.md 2>/dev/null || echo '(no SKILL.md)'" \
--preview-window=right:60%:wrap < "$tmp") --preview-window=right:60%:wrap < "$tmp")
else else
selection=$(fzf --multi --height=80% \ selection=$(fzf --multi --height=80% \
--delimiter='\t' --with-nth=1,2 \ --delimiter='\t' --with-nth=1,2 \
--prompt="disable-search > " \ --prompt="disable-search > " \
--header="TAB: toggle | ENTER: disable selected" \ --header="TAB: toggle | ENTER: disable selected" \
--preview="sed -n '1,40p' \"$ACTIVE/{1}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \ --preview="sed -n '1,40p' \"$ACTIVE\"/{1}/SKILL.md 2>/dev/null || echo '(no SKILL.md)'" \
--preview-window=right:60%:wrap < "$tmp") --preview-window=right:60%:wrap < "$tmp")
fi fi
rm -f "$tmp" rm -f "$tmp"
@@ -557,7 +613,7 @@ cmd_enable_category() {
done <<< "$selection" done <<< "$selection"
} }
# interactive pick: category → multi-select skills # interactive pick: category → multi-select skills → pick target(s)
cmd_pick() { cmd_pick() {
require_dirs require_dirs
ensure_fzf ensure_fzf
@@ -572,14 +628,19 @@ cmd_pick() {
| awk -v p="$category" '$0 ~ "^" p "(-|$)"' \ | awk -v p="$category" '$0 ~ "^" p "(-|$)"' \
| fzf --multi --height=80% \ | fzf --multi --height=80% \
--prompt="$category > " \ --prompt="$category > " \
--header="TAB: toggle | ENTER: enable selected" \ --header="TAB: toggle | ENTER: confirm → pick target" \
--preview="sed -n '1,40p' \"$PARKED/{}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \ --preview="sed -n '1,40p' \"$PARKED\"/{}/SKILL.md 2>/dev/null || echo '(no SKILL.md)'" \
--preview-window=right:60%:wrap) --preview-window=right:60%:wrap)
[ -n "$selection" ] || { echo "cancelled"; exit 0; } [ -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 local n
while IFS= read -r n; do while IFS= read -r n; do
[ -z "$n" ] && continue [ -z "$n" ] && continue
cmd_enable "$n" || true cmd_enable --target "$targets" "$n" || true
done <<< "$selection" done <<< "$selection"
} }
@@ -600,14 +661,14 @@ cmd_search() {
--delimiter='\t' --with-nth=1,2 \ --delimiter='\t' --with-nth=1,2 \
--prompt="search > " \ --prompt="search > " \
--header="TAB: toggle | ENTER: enable selected" \ --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="sed -n '1,40p' \"$PARKED\"/{1}/SKILL.md 2>/dev/null || echo '(not parked — maybe already active)'" \
--preview-window=right:60%:wrap < "$tmp") --preview-window=right:60%:wrap < "$tmp")
else else
selection=$(fzf --multi --height=80% \ selection=$(fzf --multi --height=80% \
--delimiter='\t' --with-nth=1,2 \ --delimiter='\t' --with-nth=1,2 \
--prompt="search > " \ --prompt="search > " \
--header="TAB: toggle | ENTER: enable selected" \ --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="sed -n '1,40p' \"$PARKED\"/{1}/SKILL.md 2>/dev/null || echo '(not parked — maybe already active)'" \
--preview-window=right:60%:wrap < "$tmp") --preview-window=right:60%:wrap < "$tmp")
fi fi
rm -f "$tmp" rm -f "$tmp"
@@ -622,7 +683,7 @@ cmd_search() {
# reindex: scan parked + per-target active dirs, regenerate INDEX.json / INDEX.md # reindex: scan parked + per-target active dirs, regenerate INDEX.json / INDEX.md
cmd_reindex() { cmd_reindex() {
require_dirs require_dirs
python3 - "$PARKED" "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" "$INDEX_JSON" "$INDEX_MD" <<'PY' python3 - "$PARKED" "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" "$ACTIVE_FEYNMAN" "$INDEX_JSON" "$INDEX_MD" <<'PY'
import sys, os, re, json import sys, os, re, json
from pathlib import Path from pathlib import Path
@@ -635,8 +696,9 @@ except Exception:
parked = Path(sys.argv[1]) parked = Path(sys.argv[1])
active_opencode = Path(sys.argv[2]) active_opencode = Path(sys.argv[2])
active_claude = Path(sys.argv[3]) active_claude = Path(sys.argv[3])
out_json = Path(sys.argv[4]) active_feynman = Path(sys.argv[4])
out_md = Path(sys.argv[5]) out_json = Path(sys.argv[5])
out_md = Path(sys.argv[6])
def parse_frontmatter(fm: str) -> dict: def parse_frontmatter(fm: str) -> dict:
if HAVE_YAML: if HAVE_YAML:
@@ -709,6 +771,7 @@ def scan_target(base: Path, target_name: str):
scan_parked(parked) scan_parked(parked)
scan_target(active_opencode, "opencode") scan_target(active_opencode, "opencode")
scan_target(active_claude, "claude") scan_target(active_claude, "claude")
scan_target(active_feynman, "feynman")
items = sorted(seen.values(), key=lambda x: x["folder"]) items = sorted(seen.values(), key=lambda x: x["folder"])
out_json.write_text(json.dumps(items, ensure_ascii=False, indent=2)) out_json.write_text(json.dumps(items, ensure_ascii=False, indent=2))
@@ -716,7 +779,8 @@ out_json.write_text(json.dumps(items, ensure_ascii=False, indent=2))
parked_n = sum(1 for i in items if not i["active"]) 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_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_cl = sum(1 for i in items if "claude" in i["active"])
in_both = sum(1 for i in items if "opencode" in i["active"] and "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"]) in_any = sum(1 for i in items if i["active"])
from collections import Counter from collections import Counter
@@ -728,10 +792,10 @@ 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)" top_tags = ", ".join(f"{k}:{v}" for k,v in tag_c.most_common(15)) or "(none)"
lines = [ lines = [
"# Opencode + Claude Skills — Index", "# Opencode + Claude + Feynman Skills — Index",
"", "",
f"**{len(items)} unique skills** — parked: {parked_n}, active any: {in_any}, both targets: {in_both}.", 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}.", f"Per-target — opencode: {in_oc}, claude: {in_cl}, feynman: {in_fy}.",
f"Top domains — {top_doms}.", f"Top domains — {top_doms}.",
f"Top subdomains — {top_subs}.", f"Top subdomains — {top_subs}.",
f"Top tags — {top_tags}.", f"Top tags — {top_tags}.",
@@ -739,6 +803,7 @@ lines = [
f"Parked: `{parked}`", f"Parked: `{parked}`",
f"opencode active: `{active_opencode}`", f"opencode active: `{active_opencode}`",
f"claude active: `{active_claude}`", f"claude active: `{active_claude}`",
f"feynman active: `{active_feynman}`",
"", "",
"See `README.md` for the `opc-skills` CLI.", "See `README.md` for the `opc-skills` CLI.",
"", "",
@@ -755,16 +820,16 @@ for i, it in enumerate(items, 1):
f"{it.get('subdomain','') or '-'} | `{it['folder']}` | {d} | {tags_str} |" f"{it.get('subdomain','') or '-'} | `{it['folder']}` | {d} | {tags_str} |"
) )
out_md.write_text("\n".join(lines) + "\n") out_md.write_text("\n".join(lines) + "\n")
print(f"reindexed: {len(items)} skills (parked={parked_n}, opencode={in_oc}, claude={in_cl}, both={in_both})") print(f"reindexed: {len(items)} skills (parked={parked_n}, opencode={in_oc}, claude={in_cl}, feynman={in_fy}, all3={in_all3})")
PY PY
} }
usage() { usage() {
cat <<'USG' cat <<'USG'
opc-skills — multi-target skill manager (opencode + claude) opc-skills — multi-target skill manager (opencode + claude + feynman)
Targets: opencode, claude (or "both"/"all"; default = opencode,claude) Targets: opencode, claude, feynman ("all"=all three, "both"=opencode+claude legacy)
Use --target X / --target=X / -t X with enable/disable/enable-all/disable-all to scope ops. Default = opencode,claude,feynman. Override per-call with --target X / -t X.
Inspection: Inspection:
status per-target counts + parked status per-target counts + parked
@@ -781,7 +846,7 @@ Single skill:
(idempotent — skips already-active; useful as restore-from-archive) (idempotent — skips already-active; useful as restore-from-archive)
disable-all [--target T] [-y|--yes] disable every active skill across targets disable-all [--target T] [-y|--yes] disable every active skill across targets
Bulk by axis (default: opencode+claude): Bulk by axis (default: opencode+claude+feynman):
enable-category <prefix> fzf multi-pick within a verb-prefix, then enable 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 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) enable-domain <domain> bulk enable parked skills by frontmatter `domain:` (eg. cybersecurity)
@@ -806,11 +871,12 @@ Notes:
- Single source-of-truth lives in personas repo (~/Documents/personas/skills-archive). - Single source-of-truth lives in personas repo (~/Documents/personas/skills-archive).
Environment: Environment:
OPC_PARKED override parked dir (default: ~/Documents/personas/skills-archive) OPC_PARKED override parked dir (default: ~/Documents/personas/skills-archive)
OPC_ACTIVE opencode active dir (default: ~/.config/opencode/skills) OPC_ACTIVE opencode active dir (default: ~/.config/opencode/skills)
OPC_CLAUDE_ACTIVE claude active dir (default: ~/.claude/skills) OPC_CLAUDE_ACTIVE claude active dir (default: ~/.claude/skills)
OPC_TARGETS comma-separated default targets when --target omitted OPC_FEYNMAN_ACTIVE feynman active dir (default: ~/.feynman/agent/skills)
(default: opencode,claude) OPC_TARGETS comma-separated default targets when --target omitted
(default: opencode,claude,feynman)
USG USG
} }