feat: multi-target (opencode + claude) — single archive source-of-truth
- 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.
This commit is contained in:
44
README.md
44
README.md
@@ -1,8 +1,12 @@
|
|||||||
# opc-skills
|
# 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
|
## Install
|
||||||
|
|
||||||
@@ -15,24 +19,44 @@ Ensure `~/.local/bin` is on your `PATH`.
|
|||||||
## Layout it expects
|
## Layout it expects
|
||||||
|
|
||||||
```
|
```
|
||||||
~/Documents/opencode-skills-parked/ # full catalog — untracked data dir
|
~/Documents/personas/skills-archive/ # full catalog — single source-of-truth
|
||||||
<skill-name>/SKILL.md
|
<skill-name>/SKILL.md
|
||||||
INDEX.json
|
INDEX.json
|
||||||
INDEX.md
|
INDEX.md
|
||||||
~/.config/opencode/skills/ # only currently-enabled skills
|
|
||||||
|
~/.config/opencode/skills/ # currently-enabled (opencode target)
|
||||||
|
<skill-name>/SKILL.md
|
||||||
|
|
||||||
|
~/.claude/skills/ # currently-enabled (claude target)
|
||||||
<skill-name>/SKILL.md
|
<skill-name>/SKILL.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(`~/Documents/opencode-skills-parked` is symlinked to `personas/skills-archive` for backward-compat.)
|
||||||
|
|
||||||
Override via env:
|
Override via env:
|
||||||
- `OPC_PARKED` — parked catalog root (default `~/Documents/opencode-skills-parked`)
|
- `OPC_PARKED` — parked catalog root (default `~/Documents/personas/skills-archive`)
|
||||||
- `OPC_ACTIVE` — active skills root (default `~/.config/opencode/skills`)
|
- `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
|
## Commands
|
||||||
|
|
||||||
### Inspection
|
### 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 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
|
||||||
@@ -43,9 +67,9 @@ opc-skills tags # frontmatter `tags:` distribution
|
|||||||
### Single-skill operations
|
### Single-skill operations
|
||||||
|
|
||||||
```
|
```
|
||||||
opc-skills enable <folder> # copy parked → active
|
opc-skills enable [--target T] <folder> # copy parked → active in selected targets
|
||||||
opc-skills disable <folder> # remove from active (parked preserved)
|
opc-skills disable [--target T] <folder> # remove from active in selected targets
|
||||||
opc-skills disable-all [-y|--yes] # disable every active skill
|
opc-skills disable-all [--target T] [-y|--yes] # disable every active skill across targets
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bulk by axis
|
### Bulk by axis
|
||||||
|
|||||||
318
bin/opc-skills
318
bin/opc-skills
@@ -1,20 +1,82 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# opc-skills — opencode skill enable/disable manager
|
# opc-skills — multi-target skill enable/disable manager (opencode + claude)
|
||||||
# Parked: ~/Documents/opencode-skills-parked
|
#
|
||||||
# Active: ~/.config/opencode/skills
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
PARKED="${OPC_PARKED:-$HOME/Documents/opencode-skills-parked}"
|
PARKED="${OPC_PARKED:-$HOME/Documents/personas/skills-archive}"
|
||||||
ACTIVE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}"
|
ACTIVE_OPENCODE="${OPC_ACTIVE:-$HOME/.config/opencode/skills}"
|
||||||
|
ACTIVE_CLAUDE="${OPC_CLAUDE_ACTIVE:-$HOME/.claude/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_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() {
|
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"
|
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() {
|
skill_dirs_in() {
|
||||||
# list skill folder names in a given dir (exclude bin/, files, hidden)
|
# list skill folder names in a given dir (exclude bin/, files, hidden)
|
||||||
local base="$1"
|
local base="$1"
|
||||||
@@ -25,11 +87,24 @@ skill_dirs_in() {
|
|||||||
|
|
||||||
cmd_status() {
|
cmd_status() {
|
||||||
require_dirs
|
require_dirs
|
||||||
local active parked
|
local parked; parked=$(skill_dirs_in "$PARKED" | wc -l)
|
||||||
active=$(skill_dirs_in "$ACTIVE" | wc -l)
|
printf "parked : %5d (%s)\n" "$parked" "$PARKED"
|
||||||
parked=$(skill_dirs_in "$PARKED" | wc -l)
|
echo
|
||||||
printf "active : %5d (%s)\n" "$active" "$ACTIVE"
|
printf "%-10s %5s %s\n" "target" "count" "dir"
|
||||||
printf "parked : %5d (%s)\n" "$parked" "$PARKED"
|
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() {
|
cmd_list() {
|
||||||
@@ -90,7 +165,7 @@ cmd_enable_domain() {
|
|||||||
[ -n "$d" ] || { echo "usage: opc-skills enable-domain <domain>" >&2; exit 2; }
|
[ -n "$d" ] || { echo "usage: opc-skills enable-domain <domain>" >&2; exit 2; }
|
||||||
local -a matches=()
|
local -a matches=()
|
||||||
mapfile -t matches < <(jq -r --arg d "$d" '
|
mapfile -t matches < <(jq -r --arg d "$d" '
|
||||||
.[] | select((.domain // "") == $d and (.status == "parked" or .status == "both")) | .folder
|
.[] | select((.domain // "") == $d) | .folder
|
||||||
' "$INDEX_JSON")
|
' "$INDEX_JSON")
|
||||||
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in domain: $d" >&2; exit 1; }
|
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in domain: $d" >&2; exit 1; }
|
||||||
echo "Will enable ${#matches[@]} skill(s) in domain '$d':"
|
echo "Will enable ${#matches[@]} skill(s) in domain '$d':"
|
||||||
@@ -107,7 +182,7 @@ cmd_disable_domain() {
|
|||||||
[ -n "$d" ] || { echo "usage: opc-skills disable-domain <domain>" >&2; exit 2; }
|
[ -n "$d" ] || { echo "usage: opc-skills disable-domain <domain>" >&2; exit 2; }
|
||||||
local -a matches=()
|
local -a matches=()
|
||||||
mapfile -t matches < <(jq -r --arg d "$d" '
|
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")
|
' "$INDEX_JSON")
|
||||||
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in domain: $d" >&2; exit 1; }
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in domain: $d" >&2; exit 1; }
|
||||||
echo "Will disable ${#matches[@]} skill(s) in domain '$d':"
|
echo "Will disable ${#matches[@]} skill(s) in domain '$d':"
|
||||||
@@ -124,7 +199,7 @@ cmd_enable_subdomain() {
|
|||||||
[ -n "$s" ] || { echo "usage: opc-skills enable-subdomain <subdomain>" >&2; exit 2; }
|
[ -n "$s" ] || { echo "usage: opc-skills enable-subdomain <subdomain>" >&2; exit 2; }
|
||||||
local -a matches=()
|
local -a matches=()
|
||||||
mapfile -t matches < <(jq -r --arg s "$s" '
|
mapfile -t matches < <(jq -r --arg s "$s" '
|
||||||
.[] | select((.subdomain // "") == $s and (.status == "parked" or .status == "both")) | .folder
|
.[] | select((.subdomain // "") == $s) | .folder
|
||||||
' "$INDEX_JSON")
|
' "$INDEX_JSON")
|
||||||
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in subdomain: $s" >&2; exit 1; }
|
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in subdomain: $s" >&2; exit 1; }
|
||||||
echo "Will enable ${#matches[@]} skill(s) in subdomain '$s':"
|
echo "Will enable ${#matches[@]} skill(s) in subdomain '$s':"
|
||||||
@@ -141,7 +216,7 @@ cmd_disable_subdomain() {
|
|||||||
[ -n "$s" ] || { echo "usage: opc-skills disable-subdomain <subdomain>" >&2; exit 2; }
|
[ -n "$s" ] || { echo "usage: opc-skills disable-subdomain <subdomain>" >&2; exit 2; }
|
||||||
local -a matches=()
|
local -a matches=()
|
||||||
mapfile -t matches < <(jq -r --arg s "$s" '
|
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")
|
' "$INDEX_JSON")
|
||||||
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in subdomain: $s" >&2; exit 1; }
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in subdomain: $s" >&2; exit 1; }
|
||||||
echo "Will disable ${#matches[@]} skill(s) in subdomain '$s':"
|
echo "Will disable ${#matches[@]} skill(s) in subdomain '$s':"
|
||||||
@@ -161,11 +236,11 @@ cmd_enable_tag() {
|
|||||||
local -a matches=()
|
local -a matches=()
|
||||||
if [ "$mode" = "all" ]; then
|
if [ "$mode" = "all" ]; then
|
||||||
# intersection: all given tags must appear
|
# 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 "$@")
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
||||||
else
|
else
|
||||||
# union: any given tag matches
|
# 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 "$@")
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
||||||
fi
|
fi
|
||||||
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills with tag(s): $*" >&2; exit 1; }
|
[ "${#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] <tag>..." >&2; exit 2; }
|
[ "$#" -gt 0 ] || { echo "usage: opc-skills disable-tag [--all] <tag>..." >&2; exit 2; }
|
||||||
local -a matches=()
|
local -a matches=()
|
||||||
if [ "$mode" = "all" ]; then
|
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 "$@")
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
||||||
else
|
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 "$@")
|
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
|
||||||
fi
|
fi
|
||||||
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills with tag(s): $*" >&2; exit 1; }
|
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills with tag(s): $*" >&2; exit 1; }
|
||||||
@@ -208,71 +283,116 @@ sync_shared_refs() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# incremental INDEX status update (cheap; avoids full reindex per enable/disable)
|
# incremental INDEX status update — per-target (active set is JSON array)
|
||||||
update_index_status() {
|
update_index_active_set() {
|
||||||
local folder="$1" new_status="$2"
|
local folder="$1" target="$2" op="$3" # op = add|remove
|
||||||
[ -f "$INDEX_JSON" ] || return 0
|
[ -f "$INDEX_JSON" ] || return 0
|
||||||
command -v jq >/dev/null || return 0
|
command -v jq >/dev/null || return 0
|
||||||
local tmp; tmp=$(mktemp)
|
local tmp; tmp=$(mktemp)
|
||||||
jq --arg f "$folder" --arg s "$new_status" \
|
jq --arg f "$folder" --arg t "$target" --arg op "$op" '
|
||||||
'(.[] | select(.folder == $f) | .status) = $s' \
|
map(if .folder == $f then
|
||||||
"$INDEX_JSON" > "$tmp" && mv "$tmp" "$INDEX_JSON"
|
.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] <folder>
|
||||||
cmd_enable() {
|
cmd_enable() {
|
||||||
|
parse_target_flag "$@"
|
||||||
|
set -- "${REMAINING_ARGS[@]}"
|
||||||
require_dirs
|
require_dirs
|
||||||
local name="${1:-}"
|
local name="${1:-}"
|
||||||
[ -n "$name" ] || { echo "usage: opc-skills enable <folder>" >&2; exit 2; }
|
[ -n "$name" ] || { echo "usage: opc-skills enable [--target opencode|claude|both] <folder>" >&2; exit 2; }
|
||||||
local src="$PARKED/$name" dst="$ACTIVE/$name"
|
local src="$PARKED/$name"
|
||||||
[ -d "$src" ] || { echo "not parked: $name" >&2; exit 1; }
|
[ -d "$src" ] || { echo "not parked: $name" >&2; exit 1; }
|
||||||
[ -d "$dst" ] && { echo "already active: $name"; exit 0; }
|
local t base dst
|
||||||
cp -r "$src" "$dst"
|
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||||||
sync_shared_refs
|
for t in "${tgts[@]}"; do
|
||||||
update_index_status "$name" "both"
|
base=$(target_dir "$t") || continue
|
||||||
echo "enabled: $name"
|
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() {
|
cmd_disable() {
|
||||||
|
parse_target_flag "$@"
|
||||||
|
set -- "${REMAINING_ARGS[@]}"
|
||||||
require_dirs
|
require_dirs
|
||||||
local name="${1:-}"
|
local name="${1:-}"
|
||||||
[ -n "$name" ] || { echo "usage: opc-skills disable <folder>" >&2; exit 2; }
|
[ -n "$name" ] || { echo "usage: opc-skills disable [--target opencode|claude|both] <folder>" >&2; exit 2; }
|
||||||
local src="$ACTIVE/$name" dst="$PARKED/$name"
|
local t base src
|
||||||
[ -d "$src" ] || { echo "not active: $name" >&2; exit 1; }
|
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||||||
if [ -d "$dst" ]; then
|
for t in "${tgts[@]}"; do
|
||||||
# parked copy exists; just remove active
|
base=$(target_dir "$t") || continue
|
||||||
|
src="$base/$name"
|
||||||
|
if [ ! -d "$src" ]; then
|
||||||
|
echo "[$t] not active: $name"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
rm -rf "$src"
|
rm -rf "$src"
|
||||||
update_index_status "$name" "parked"
|
update_index_active_set "$name" "$t" "remove"
|
||||||
echo "removed active copy (parked version kept): $name"
|
echo "[$t] disabled: $name"
|
||||||
else
|
done
|
||||||
mv "$src" "$dst"
|
|
||||||
update_index_status "$name" "parked"
|
|
||||||
echo "disabled (moved to parked): $name"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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() {
|
cmd_disable_all() {
|
||||||
|
parse_target_flag "$@"
|
||||||
|
set -- "${REMAINING_ARGS[@]}"
|
||||||
require_dirs
|
require_dirs
|
||||||
local force=0
|
local force=0
|
||||||
if [ "${1:-}" = "-y" ] || [ "${1:-}" = "--yes" ]; then
|
if [ "${1:-}" = "-y" ] || [ "${1:-}" = "--yes" ]; then
|
||||||
force=1
|
force=1
|
||||||
fi
|
fi
|
||||||
|
IFS=',' read -ra tgts <<< "$PARSED_TARGETS"
|
||||||
|
# Union of active skills across selected targets
|
||||||
local -a actives
|
local -a actives
|
||||||
mapfile -t actives < <(skill_dirs_in "$ACTIVE")
|
mapfile -t actives < <(
|
||||||
[ "${#actives[@]}" -gt 0 ] || { echo "no active skills"; exit 0; }
|
for t in "${tgts[@]}"; do active_dirs_for_target "$t"; done | sort -u
|
||||||
echo "Will disable ${#actives[@]} active skill(s):"
|
)
|
||||||
|
[ "${#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[@]}"
|
printf ' %s\n' "${actives[@]}"
|
||||||
if [ "$force" -ne 1 ]; then
|
if [ "$force" -ne 1 ]; then
|
||||||
printf "Proceed? [y/N] "
|
printf "Proceed? [y/N] "
|
||||||
read -r ans </dev/tty || ans=""
|
read -r ans </dev/tty || ans=""
|
||||||
case "$ans" in y|Y|yes|YES) ;; *) echo "cancelled"; exit 0 ;; esac
|
case "$ans" in y|Y|yes|YES) ;; *) echo "cancelled"; exit 0 ;; esac
|
||||||
fi
|
fi
|
||||||
local n
|
local n t base
|
||||||
for n in "${actives[@]}"; do
|
for n in "${actives[@]}"; do
|
||||||
cmd_disable "$n" || true
|
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
|
done
|
||||||
|
echo "done."
|
||||||
}
|
}
|
||||||
|
|
||||||
# batch disable by prefix/category — fzf multi-pick within ACTIVE
|
# batch disable by prefix/category — fzf multi-pick within ACTIVE
|
||||||
@@ -338,7 +458,7 @@ cmd_disable_search() {
|
|||||||
local tmp
|
local tmp
|
||||||
tmp=$(mktemp)
|
tmp=$(mktemp)
|
||||||
# Only active or both
|
# Only active or both
|
||||||
jq -r '.[] | select(.status=="active" or .status=="both") | "\(.folder)\t\(.description)"' "$INDEX_JSON" > "$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; }
|
[ -s "$tmp" ] || { rm -f "$tmp"; echo "no active skills indexed (try: opc-skills reindex)"; exit 0; }
|
||||||
local selection
|
local selection
|
||||||
if [ -n "$query" ]; then
|
if [ -n "$query" ]; then
|
||||||
@@ -449,10 +569,10 @@ cmd_search() {
|
|||||||
done <<< "$selection"
|
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() {
|
cmd_reindex() {
|
||||||
require_dirs
|
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
|
import sys, os, re, json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -463,9 +583,10 @@ except Exception:
|
|||||||
HAVE_YAML = False
|
HAVE_YAML = False
|
||||||
|
|
||||||
parked = Path(sys.argv[1])
|
parked = Path(sys.argv[1])
|
||||||
active = Path(sys.argv[2])
|
active_opencode = Path(sys.argv[2])
|
||||||
out_json = Path(sys.argv[3])
|
active_claude = Path(sys.argv[3])
|
||||||
out_md = Path(sys.argv[4])
|
out_json = Path(sys.argv[4])
|
||||||
|
out_md = Path(sys.argv[5])
|
||||||
|
|
||||||
def parse_frontmatter(fm: str) -> dict:
|
def parse_frontmatter(fm: str) -> dict:
|
||||||
if HAVE_YAML:
|
if HAVE_YAML:
|
||||||
@@ -515,25 +636,38 @@ def read_skill(d: Path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
seen = {}
|
seen = {}
|
||||||
def scan(base: Path, status: str):
|
def scan_parked(base: Path):
|
||||||
if not base.exists(): return
|
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(".")):
|
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 = read_skill(d)
|
||||||
key = d.name
|
rec["active"] = [] # per-target active list (filled below)
|
||||||
if key in seen:
|
rec["status"] = "parked"
|
||||||
seen[key]["status"] = "both"
|
seen[d.name] = rec
|
||||||
else:
|
|
||||||
rec["status"] = status
|
|
||||||
seen[key] = rec
|
|
||||||
|
|
||||||
scan(active, "active")
|
def scan_target(base: Path, target_name: str):
|
||||||
scan(parked, "parked")
|
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"])
|
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))
|
||||||
|
|
||||||
active_n = sum(1 for i in items if i["status"] in ("active", "both"))
|
parked_n = sum(1 for i in items if not i["active"])
|
||||||
parked_n = sum(1 for i in items if i["status"] in ("parked", "both"))
|
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
|
from collections import Counter
|
||||||
dom_c = Counter(i["domain"] for i in items if i["domain"])
|
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)"
|
top_tags = ", ".join(f"{k}:{v}" for k,v in tag_c.most_common(15)) or "(none)"
|
||||||
|
|
||||||
lines = [
|
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 domains — {top_doms}.",
|
||||||
f"Top subdomains — {top_subs}.",
|
f"Top subdomains — {top_subs}.",
|
||||||
f"Top tags — {top_tags}.",
|
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.",
|
"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):
|
for i, it in enumerate(items, 1):
|
||||||
d = it["description"].replace("\n", " ").replace("|", "\\|")
|
d = it["description"].replace("\n", " ").replace("|", "\\|")
|
||||||
if len(d) > 160: d = d[:157] + "..."
|
if len(d) > 160: d = d[:157] + "..."
|
||||||
tags_str = ", ".join(it.get("tags", [])[:6])
|
tags_str = ", ".join(it.get("tags", [])[:6])
|
||||||
|
active_str = ",".join(it.get("active", [])) or "-"
|
||||||
lines.append(
|
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} |"
|
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 (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
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'USG'
|
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:
|
Inspection:
|
||||||
status counts of active vs parked
|
status per-target counts + parked
|
||||||
list {active|parked|all} list skill folder names
|
list {active|parked|all} list skill folder names (active = opencode set)
|
||||||
categories / cats verb-prefix category counts (eg. performing, detecting)
|
categories / cats verb-prefix category counts (eg. performing, detecting)
|
||||||
domains frontmatter `domain:` distribution
|
domains frontmatter `domain:` distribution
|
||||||
subdomains frontmatter `subdomain:` distribution
|
subdomains frontmatter `subdomain:` distribution
|
||||||
tags frontmatter `tags:` distribution
|
tags frontmatter `tags:` distribution
|
||||||
|
|
||||||
Single skill:
|
Single skill:
|
||||||
enable <folder> enable single skill (copy parked → active)
|
enable [--target T] <folder> copy parked → active in selected targets
|
||||||
disable <folder> disable single skill (remove from active; keep parked)
|
disable [--target T] <folder> remove from active in selected targets (parked kept)
|
||||||
disable-all [-y|--yes] disable every active skill (asks for confirmation)
|
disable-all [--target T] [-y|--yes] disable every active skill across targets
|
||||||
|
|
||||||
Bulk by axis:
|
Bulk by axis (default: opencode+claude):
|
||||||
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)
|
||||||
@@ -603,16 +744,21 @@ Interactive / search:
|
|||||||
disable-pick fzf: choose ACTIVE category → multi-select → disable
|
disable-pick fzf: choose ACTIVE category → multi-select → disable
|
||||||
search [query] fzf fuzzy search across name+description (enable)
|
search [query] fzf fuzzy search across name+description (enable)
|
||||||
disable-search [query] fzf fuzzy search ACTIVE skills only (disable)
|
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:
|
Notes:
|
||||||
- disable* commands never delete data — they remove the active copy and keep
|
- disable* commands never delete data — they remove the active copy and keep
|
||||||
(or restore) the parked copy. To re-enable: opc-skills enable <folder>.
|
(or restore) the parked copy. To re-enable: opc-skills enable <folder>.
|
||||||
- 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:
|
Environment:
|
||||||
OPC_PARKED override parked dir (default: ~/Documents/opencode-skills-parked)
|
OPC_PARKED override parked dir (default: ~/Documents/personas/skills-archive)
|
||||||
OPC_ACTIVE override 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_TARGETS comma-separated default targets when --target omitted
|
||||||
|
(default: opencode,claude)
|
||||||
USG
|
USG
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user