From a6e9fedf7294bc89e68ad731e56600b953abe829 Mon Sep 17 00:00:00 2001 From: salva Date: Thu, 30 Apr 2026 23:47:02 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20multi-target=20(opencode=20+=20claude)?= =?UTF-8?q?=20=E2=80=94=20single=20archive=20source-of-truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default targets: opencode + claude (override via --target / OPC_TARGETS) - enable/disable/disable-all now iterate over selected targets - INDEX schema: per-target 'active' array (replaces legacy 'status' enum) - Status command shows per-target counts + parked - Reindex scans both ~/.config/opencode/skills and ~/.claude/skills - PARKED moved to ~/Documents/personas/skills-archive (backward-compat symlink kept) - bulk filters (domain/subdomain/tag) updated to per-target schema - jq filters no longer reference legacy 'status' values Migration: previous OPC_PARKED at ~/Documents/opencode-skills-parked is now a symlink to personas/skills-archive — existing scripts continue to work. This addresses the opencode 1.14 'external skills' bloat: opencode auto-scans ~/.claude/skills as well as its own dir, so a 1000-skill Claude archive bloats the prompt. opc-skills now manages both targets explicitly + lazily. --- README.md | 44 +++++-- bin/opc-skills | 318 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 266 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 3b8abfc..7175d4e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # opc-skills -Small shell utility for toggling opencode skills between a curated "active" set and a full "parked" catalog. Keeps `~/.config/opencode/skills/` small and intentional while preserving the full skill library offline. +**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. -This is deliberately a quick, standalone tool — it is **not** a general AI persona manager. For agent / skill / persona management across multiple AI platforms (Claude Code, opencode, Gemini, Paperclip, ...), see the `personas` repo. +## 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. + +For full agent / skill / persona generation across multiple AI platforms, see the upstream [`personas`](https://gitea.taygun.net.tr/salvacybersec/personas) repo. ## Install @@ -15,24 +19,44 @@ Ensure `~/.local/bin` is on your `PATH`. ## Layout it expects ``` -~/Documents/opencode-skills-parked/ # full catalog — untracked data dir +~/Documents/personas/skills-archive/ # full catalog — single source-of-truth /SKILL.md INDEX.json INDEX.md -~/.config/opencode/skills/ # only currently-enabled skills + +~/.config/opencode/skills/ # currently-enabled (opencode target) + /SKILL.md + +~/.claude/skills/ # currently-enabled (claude target) /SKILL.md ``` +(`~/Documents/opencode-skills-parked` is symlinked to `personas/skills-archive` for backward-compat.) + Override via env: -- `OPC_PARKED` — parked catalog root (default `~/Documents/opencode-skills-parked`) -- `OPC_ACTIVE` — active skills root (default `~/.config/opencode/skills`) +- `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`) + +## Targets + +`enable`, `disable`, and `disable-all` accept `--target` / `-t`: + +| flag | meaning | +|---|---| +| `--target opencode` | only `~/.config/opencode/skills/` | +| `--target claude` | only `~/.claude/skills/` | +| `--target both` (or `all`) | both — also the default | + +When omitted, the default is taken from `OPC_TARGETS` (default `opencode,claude`). ## Commands ### Inspection ``` -opc-skills status # counts of active vs parked +opc-skills status # parked + per-target active counts opc-skills list {active|parked|all} # skill folder names opc-skills categories # verb-prefix counts (performing, detecting, ...) opc-skills domains # frontmatter `domain:` distribution @@ -43,9 +67,9 @@ opc-skills tags # frontmatter `tags:` distribution ### Single-skill operations ``` -opc-skills enable # copy parked → active -opc-skills disable # remove from active (parked preserved) -opc-skills disable-all [-y|--yes] # disable every active skill +opc-skills enable [--target T] # copy parked → active in selected targets +opc-skills disable [--target T] # remove from active in selected targets +opc-skills disable-all [--target T] [-y|--yes] # disable every active skill across targets ``` ### Bulk by axis diff --git a/bin/opc-skills b/bin/opc-skills index 037dd41..ba973ba 100755 --- a/bin/opc-skills +++ b/bin/opc-skills @@ -1,20 +1,82 @@ #!/usr/bin/env bash -# opc-skills — opencode skill enable/disable manager -# Parked: ~/Documents/opencode-skills-parked -# Active: ~/.config/opencode/skills +# opc-skills — multi-target skill enable/disable manager (opencode + claude) +# +# 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 +# +# Default targets when --target is omitted: opencode,claude (set OPC_TARGETS to override) set -euo pipefail -PARKED="${OPC_PARKED:-$HOME/Documents/opencode-skills-parked}" -ACTIVE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}" +PARKED="${OPC_PARKED:-$HOME/Documents/personas/skills-archive}" +ACTIVE_OPENCODE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}" +ACTIVE_CLAUDE="${OPC_CLAUDE_ACTIVE:-$HOME/.claude/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}" + +# 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 ;; + esac +} + +# expand "both"/"all"/"opencode,claude" → ordered unique list of valid targets +resolve_targets() { + local raw="${1:-$DEFAULT_TARGETS}" + case "$raw" in + both|all) 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" + mkdir -p "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" } +# 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" @@ -25,11 +87,24 @@ skill_dirs_in() { cmd_status() { require_dirs - local active parked - active=$(skill_dirs_in "$ACTIVE" | wc -l) - parked=$(skill_dirs_in "$PARKED" | wc -l) - printf "active : %5d (%s)\n" "$active" "$ACTIVE" - printf "parked : %5d (%s)\n" "$parked" "$PARKED" + 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; 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 + 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 .) + echo + printf "any target : %5d\n" "$any" + printf "both : %5d\n" "$both" } cmd_list() { @@ -90,7 +165,7 @@ cmd_enable_domain() { [ -n "$d" ] || { echo "usage: opc-skills enable-domain " >&2; exit 2; } local -a matches=() mapfile -t matches < <(jq -r --arg d "$d" ' - .[] | select((.domain // "") == $d and (.status == "parked" or .status == "both")) | .folder + .[] | 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':" @@ -107,7 +182,7 @@ cmd_disable_domain() { [ -n "$d" ] || { echo "usage: opc-skills disable-domain " >&2; exit 2; } local -a matches=() mapfile -t matches < <(jq -r --arg d "$d" ' - .[] | select((.domain // "") == $d and (.status == "active" or .status == "both")) | .folder + .[] | 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':" @@ -124,7 +199,7 @@ cmd_enable_subdomain() { [ -n "$s" ] || { echo "usage: opc-skills enable-subdomain " >&2; exit 2; } local -a matches=() mapfile -t matches < <(jq -r --arg s "$s" ' - .[] | select((.subdomain // "") == $s and (.status == "parked" or .status == "both")) | .folder + .[] | 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':" @@ -141,7 +216,7 @@ cmd_disable_subdomain() { [ -n "$s" ] || { echo "usage: opc-skills disable-subdomain " >&2; exit 2; } local -a matches=() mapfile -t matches < <(jq -r --arg s "$s" ' - .[] | select((.subdomain // "") == $s and (.status == "active" or .status == "both")) | .folder + .[] | 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':" @@ -161,11 +236,11 @@ cmd_enable_tag() { local -a matches=() if [ "$mode" = "all" ]; then # intersection: all given tags must appear - local jq_filter='. | map(select((.status == "parked" or .status == "both") and ([.tags // [] | .[]] as $t | ($ARGS.positional | all(. as $x | $t | index($x)))))) | .[].folder' + 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((.status == "parked" or .status == "both") and ((.tags // []) | any(. as $t | $want | index($t)))) | .folder' + 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; } @@ -184,10 +259,10 @@ cmd_disable_tag() { [ "$#" -gt 0 ] || { echo "usage: opc-skills disable-tag [--all] ..." >&2; exit 2; } local -a matches=() if [ "$mode" = "all" ]; then - local jq_filter='. | map(select((.status == "active" or .status == "both") and ([.tags // [] | .[]] as $t | ($ARGS.positional | all(. as $x | $t | index($x)))))) | .[].folder' + 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((.status == "active" or .status == "both") and ((.tags // []) | any(. as $t | $want | index($t)))) | .folder' + 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; } @@ -208,71 +283,116 @@ sync_shared_refs() { done } -# incremental INDEX status update (cheap; avoids full reindex per enable/disable) -update_index_status() { - local folder="$1" new_status="$2" +# 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 s "$new_status" \ - '(.[] | select(.folder == $f) | .status) = $s' \ - "$INDEX_JSON" > "$tmp" && mv "$tmp" "$INDEX_JSON" + 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" } -# enable one skill by folder name +# 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 + 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] cmd_enable() { + parse_target_flag "$@" + set -- "${REMAINING_ARGS[@]}" require_dirs local name="${1:-}" - [ -n "$name" ] || { echo "usage: opc-skills enable " >&2; exit 2; } - local src="$PARKED/$name" dst="$ACTIVE/$name" + [ -n "$name" ] || { echo "usage: opc-skills enable [--target opencode|claude|both] " >&2; exit 2; } + local src="$PARKED/$name" [ -d "$src" ] || { echo "not parked: $name" >&2; exit 1; } - [ -d "$dst" ] && { echo "already active: $name"; exit 0; } - cp -r "$src" "$dst" - sync_shared_refs - update_index_status "$name" "both" - echo "enabled: $name" + 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 active skill (move back to parked) +# 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 " >&2; exit 2; } - local src="$ACTIVE/$name" dst="$PARKED/$name" - [ -d "$src" ] || { echo "not active: $name" >&2; exit 1; } - if [ -d "$dst" ]; then - # parked copy exists; just remove active + [ -n "$name" ] || { echo "usage: opc-skills disable [--target opencode|claude|both] " >&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_status "$name" "parked" - echo "removed active copy (parked version kept): $name" - else - mv "$src" "$dst" - update_index_status "$name" "parked" - echo "disabled (moved to parked): $name" - fi + update_index_active_set "$name" "$t" "remove" + echo "[$t] disabled: $name" + done } -# disable all active skills (with confirmation; -y/--yes to skip prompt) +# 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 < <(skill_dirs_in "$ACTIVE") - [ "${#actives[@]}" -gt 0 ] || { echo "no active skills"; exit 0; } - echo "Will disable ${#actives[@]} active skill(s):" + 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 "$tmp" + 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 @@ -449,10 +569,10 @@ cmd_search() { done <<< "$selection" } -# reindex: scan parked + active, regenerate INDEX.json / INDEX.md +# reindex: scan parked + per-target active dirs, regenerate INDEX.json / INDEX.md cmd_reindex() { require_dirs - python3 - "$PARKED" "$ACTIVE" "$INDEX_JSON" "$INDEX_MD" <<'PY' + python3 - "$PARKED" "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" "$INDEX_JSON" "$INDEX_MD" <<'PY' import sys, os, re, json from pathlib import Path @@ -463,9 +583,10 @@ except Exception: HAVE_YAML = False parked = Path(sys.argv[1]) -active = Path(sys.argv[2]) -out_json = Path(sys.argv[3]) -out_md = Path(sys.argv[4]) +active_opencode = Path(sys.argv[2]) +active_claude = Path(sys.argv[3]) +out_json = Path(sys.argv[4]) +out_md = Path(sys.argv[5]) def parse_frontmatter(fm: str) -> dict: if HAVE_YAML: @@ -515,25 +636,38 @@ def read_skill(d: Path): } seen = {} -def scan(base: Path, status: str): +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) - key = d.name - if key in seen: - seen[key]["status"] = "both" - else: - rec["status"] = status - seen[key] = rec + rec["active"] = [] # per-target active list (filled below) + rec["status"] = "parked" + seen[d.name] = rec -scan(active, "active") -scan(parked, "parked") +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") items = sorted(seen.values(), key=lambda x: x["folder"]) out_json.write_text(json.dumps(items, ensure_ascii=False, indent=2)) -active_n = sum(1 for i in items if i["status"] in ("active", "both")) -parked_n = sum(1 for i in items if i["status"] in ("parked", "both")) +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_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"]) @@ -544,51 +678,58 @@ top_subs = ", ".join(f"{k}:{v}" for k,v in sub_c.most_common(10)) or "(none)" top_tags = ", ".join(f"{k}:{v}" for k,v in tag_c.most_common(15)) or "(none)" lines = [ - "# Opencode Skills — Index", + "# Opencode + Claude Skills — Index", "", - f"**{len(items)} unique skills** — active: {active_n}, parked: {parked_n}.", + 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"Top domains — {top_doms}.", f"Top subdomains — {top_subs}.", f"Top tags — {top_tags}.", "", - f"Active: `{active}` | Parked: `{parked}`", + f"Parked: `{parked}`", + f"opencode active: `{active_opencode}`", + f"claude active: `{active_claude}`", "", "See `README.md` for the `opc-skills` CLI.", "", - "| # | Status | Domain | Subdomain | Folder | Description | Tags |", + "| # | 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} | {it['status']} | {it.get('domain','') or '-'} | " + 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 (active={active_n}, parked={parked_n})") +print(f"reindexed: {len(items)} skills (parked={parked_n}, opencode={in_oc}, claude={in_cl}, both={in_both})") PY } usage() { cat <<'USG' -opc-skills — opencode skill manager +opc-skills — multi-target skill manager (opencode + claude) + +Targets: opencode, claude (or "both"/"all"; default = opencode,claude) +Use --target X / --target=X / -t X with enable/disable/disable-all to scope ops. Inspection: - status counts of active vs parked - list {active|parked|all} list skill folder names + 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 enable single skill (copy parked → active) - disable disable single skill (remove from active; keep parked) - disable-all [-y|--yes] disable every active skill (asks for confirmation) + enable [--target T] copy parked → active in selected targets + disable [--target T] remove from active in selected targets (parked kept) + disable-all [--target T] [-y|--yes] disable every active skill across targets -Bulk by axis: +Bulk by axis (default: opencode+claude): 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) @@ -603,16 +744,21 @@ Interactive / search: disable-pick fzf: choose ACTIVE category → multi-select → disable search [query] fzf fuzzy search across name+description (enable) disable-search [query] fzf fuzzy search ACTIVE skills only (disable) - reindex rebuild INDEX.json / INDEX.md (extracts domain/subdomain/tags) + 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 . - - domain/subdomain/tag commands read INDEX.json — run reindex after manual changes. + - 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/opencode-skills-parked) - OPC_ACTIVE override active dir (default: ~/.config/opencode/skills) + 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) USG }