diff --git a/README.md b/README.md index 7175d4e..7633684 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # 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 -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. @@ -29,6 +29,9 @@ Ensure `~/.local/bin` is on your `PATH`. ~/.claude/skills/ # currently-enabled (claude target) /SKILL.md + +~/.feynman/agent/skills/ # currently-enabled (feynman target) + /SKILL.md ``` (`~/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_ACTIVE` — opencode active dir (default `~/.config/opencode/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 @@ -47,16 +51,19 @@ Override via env: |---|---| | `--target opencode` | only `~/.config/opencode/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 ### 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 categories # verb-prefix counts (performing, detecting, ...) opc-skills domains # frontmatter `domain:` distribution @@ -88,27 +95,42 @@ opc-skills disable-tag [--all] ... ### Interactive / search ``` -opc-skills pick # fzf: category → multi-select → enable -opc-skills disable-pick # fzf: ACTIVE category → multi-select → disable -opc-skills search [query] # fzf fuzzy over name+description -opc-skills disable-search [query] # fzf over ACTIVE only +opc-skills pick # fzf: category → multi-select skills → target picker → enable +opc-skills disable-pick # fzf: union-of-actives → category → multi-select → target picker → disable +opc-skills search [query] # fzf fuzzy over name+description (enables on selection) +opc-skills disable-search [query] # fzf over ACTIVE only (disables on selection) 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`. ### 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 -Some skills reference sibling files via `../.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 `../.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: - `_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 diff --git a/bin/opc-skills b/bin/opc-skills index 88e71f7..d5e75cb 100755 --- a/bin/opc-skills +++ b/bin/opc-skills @@ -1,5 +1,5 @@ #!/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): # ~/Documents/personas/skills-archive (default; symlinked from old location) @@ -7,34 +7,38 @@ # 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 (set OPC_TARGETS to override) +# 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}" +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" ;; - *) 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 } -# 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() { local raw="${1:-$DEFAULT_TARGETS}" case "$raw" in - both|all) raw="opencode,claude" ;; + all) raw="opencode,claude,feynman" ;; + both) raw="opencode,claude" ;; esac printf '%s\n' "$raw" | tr ',' '\n' | awk 'NF' | awk '!seen[$0]++' } @@ -70,7 +74,7 @@ active_dirs_for_target() { require_dirs() { [ -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 @@ -92,19 +96,22 @@ cmd_status() { echo printf "%-10s %5s %s\n" "target" "count" "dir" local t n - for t in opencode claude; do + 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 both (all targets) - local in_oc in_cl both any + # 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) - both=$(comm -12 <(echo "$in_oc" | sort) <(echo "$in_cl" | sort) | grep -c .) - any=$( { echo "$in_oc"; echo "$in_cl"; } | sort -u | grep -c .) + 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 "both : %5d\n" "$both" + printf "all three : %5d\n" "$all3" } 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_to_targets() { local f t base - for t in opencode claude; do + 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 @@ -467,34 +474,83 @@ cmd_disable_category() { 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() { require_dirs ensure_fzf - local active_count - active_count=$(skill_dirs_in "$ACTIVE" | wc -l) - [ "$active_count" -gt 0 ] || { echo "no active skills"; exit 0; } - # Active categories (by prefix) + # 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=$(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 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}') - [ -n "$category" ] || { echo "cancelled"; exit 0; } + [ -n "$category" ] || { rm -f "$tmp"; echo "cancelled"; exit 0; } + + # Show "skill [targets]" lines, multi-select local selection - selection=$(skill_dirs_in "$ACTIVE" \ - | awk -v p="$category" '$0 ~ "^" p "(-|$)"' \ + selection=$(awk -F'\t' -v p="$category" '$1 ~ "^"p"(-|$)"' "$tmp" \ | fzf --multi --height=80% \ --prompt="disable $category > " \ - --header="TAB: toggle | ENTER: disable selected" \ - --preview="sed -n '1,40p' \"$ACTIVE/{}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \ + --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= read -r n; do + while IFS=$'\t' read -r n _; do [ -z "$n" ] && continue - cmd_disable "$n" || true + cmd_disable --target "$targets" "$n" || true done <<< "$selection" } @@ -516,14 +572,14 @@ cmd_disable_search() { --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="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="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" @@ -557,7 +613,7 @@ cmd_enable_category() { done <<< "$selection" } -# interactive pick: category → multi-select skills +# interactive pick: category → multi-select skills → pick target(s) cmd_pick() { require_dirs ensure_fzf @@ -572,14 +628,19 @@ cmd_pick() { | awk -v p="$category" '$0 ~ "^" p "(-|$)"' \ | fzf --multi --height=80% \ --prompt="$category > " \ - --header="TAB: toggle | ENTER: enable selected" \ - --preview="sed -n '1,40p' \"$PARKED/{}/SKILL.md\" 2>/dev/null || echo '(no SKILL.md)'" \ + --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 "$n" || true + cmd_enable --target "$targets" "$n" || true done <<< "$selection" } @@ -600,14 +661,14 @@ cmd_search() { --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="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="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" @@ -622,7 +683,7 @@ cmd_search() { # reindex: scan parked + per-target active dirs, regenerate INDEX.json / INDEX.md cmd_reindex() { 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 from pathlib import Path @@ -635,8 +696,9 @@ except Exception: parked = Path(sys.argv[1]) active_opencode = Path(sys.argv[2]) active_claude = Path(sys.argv[3]) -out_json = Path(sys.argv[4]) -out_md = Path(sys.argv[5]) +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: @@ -709,6 +771,7 @@ def scan_target(base: Path, target_name: str): 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)) @@ -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"]) 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_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"]) 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)" 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"Per-target — opencode: {in_oc}, claude: {in_cl}.", + 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}.", @@ -739,6 +803,7 @@ lines = [ 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.", "", @@ -755,16 +820,16 @@ for i, it in enumerate(items, 1): 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}, 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 } usage() { 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) -Use --target X / --target=X / -t X with enable/disable/enable-all/disable-all to scope ops. +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 @@ -781,7 +846,7 @@ Single skill: (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): +Bulk by axis (default: opencode+claude+feynman): enable-category fzf multi-pick within a verb-prefix, then enable disable-category fzf multi-pick of ACTIVE skills with prefix, then disable enable-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). 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_TARGETS comma-separated default targets when --target omitted - (default: opencode,claude) + 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 }