feat: domain/subdomain/tag bulk ops + enriched INDEX

- domains, subdomains, tags : frontmatter distribution counts
- enable-domain / disable-domain        : bulk by 'domain:' (eg. cybersecurity)
- enable-subdomain / disable-subdomain  : bulk by 'subdomain:' (eg. malware-analysis)
- enable-tag / disable-tag [--all]      : multi-tag union (default) or intersection
- INDEX.json now exposes domain, subdomain, tags[]
- INDEX.md columns expanded; top-domains/subdomains/tags summaries
- PyYAML-first frontmatter parser (regex fallback) — parses 1027/1032 descriptions
  and 758 skills' tags correctly (previous regex parser missed almost everything)
- enable/disable now incrementally update INDEX.json status so bulk ops stay
  consistent without a full reindex
- jq --args bug fix in tag commands ('--' was leaking into ARGS.positional)
This commit is contained in:
salva
2026-04-30 22:09:41 +03:00
parent 81a288e629
commit 70763091af
2 changed files with 301 additions and 34 deletions

View File

@@ -29,19 +29,53 @@ Override via env:
## Commands
### Inspection
```
opc-skills status # counts of active vs parked
opc-skills list {active|parked|all} # skill folder names
opc-skills categories # prefix-based category counts
opc-skills categories # verb-prefix counts (performing, detecting, ...)
opc-skills domains # frontmatter `domain:` distribution
opc-skills subdomains # frontmatter `subdomain:` distribution
opc-skills tags # frontmatter `tags:` distribution
```
### Single-skill operations
```
opc-skills enable <folder> # copy parked → active
opc-skills disable <folder> # remove from active (parked preserved)
opc-skills enable-category <prefix> # fzf multi-pick within a prefix
opc-skills disable-all [-y|--yes] # disable every active skill
```
### Bulk by axis
```
opc-skills enable-category <prefix> # fzf multi-pick within a verb-prefix
opc-skills disable-category <prefix>
opc-skills enable-domain <domain> # bulk by frontmatter domain (eg. cybersecurity)
opc-skills disable-domain <domain>
opc-skills enable-subdomain <subdomain> # bulk by subdomain (eg. malware-analysis)
opc-skills disable-subdomain <subdomain>
opc-skills enable-tag [--all] <tag>... # bulk by tags (default: any; --all: intersection)
opc-skills disable-tag [--all] <tag>...
```
### 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 reindex # rebuild INDEX.json / INDEX.md
```
`fzf` is required for the interactive commands.
`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.
## Shared reference files

View File

@@ -54,6 +54,149 @@ ensure_fzf() {
command -v fzf >/dev/null || { echo "fzf is required for this command" >&2; exit 1; }
}
ensure_jq() {
command -v jq >/dev/null || { echo "jq is required for this command" >&2; exit 1; }
}
# domain distribution (frontmatter `domain:` field) — uses INDEX.json
cmd_domains() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
jq -r '.[] | (.domain // "<none>")' "$INDEX_JSON" | sort | uniq -c | sort -rn
}
cmd_subdomains() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
jq -r '.[] | (.subdomain // "<none>")' "$INDEX_JSON" | sort | uniq -c | sort -rn
}
# tag distribution — flatten tags[] across all skills
cmd_tags() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
jq -r '.[] | (.tags // [])[]' "$INDEX_JSON" | sort | uniq -c | sort -rn
}
# bulk enable parked skills whose frontmatter `domain:` matches
cmd_enable_domain() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
local d="${1:-}"
[ -n "$d" ] || { echo "usage: opc-skills enable-domain <domain>" >&2; exit 2; }
local -a matches=()
mapfile -t matches < <(jq -r --arg d "$d" '
.[] | select((.domain // "") == $d and (.status == "parked" or .status == "both")) | .folder
' "$INDEX_JSON")
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in domain: $d" >&2; exit 1; }
echo "Will enable ${#matches[@]} skill(s) in domain '$d':"
printf ' %s\n' "${matches[@]}"
local n
for n in "${matches[@]}"; do cmd_enable "$n" || true; done
}
cmd_disable_domain() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
local d="${1:-}"
[ -n "$d" ] || { echo "usage: opc-skills disable-domain <domain>" >&2; exit 2; }
local -a matches=()
mapfile -t matches < <(jq -r --arg d "$d" '
.[] | select((.domain // "") == $d and (.status == "active" or .status == "both")) | .folder
' "$INDEX_JSON")
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in domain: $d" >&2; exit 1; }
echo "Will disable ${#matches[@]} skill(s) in domain '$d':"
printf ' %s\n' "${matches[@]}"
local n
for n in "${matches[@]}"; do cmd_disable "$n" || true; done
}
cmd_enable_subdomain() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
local s="${1:-}"
[ -n "$s" ] || { echo "usage: opc-skills enable-subdomain <subdomain>" >&2; exit 2; }
local -a matches=()
mapfile -t matches < <(jq -r --arg s "$s" '
.[] | select((.subdomain // "") == $s and (.status == "parked" or .status == "both")) | .folder
' "$INDEX_JSON")
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills in subdomain: $s" >&2; exit 1; }
echo "Will enable ${#matches[@]} skill(s) in subdomain '$s':"
printf ' %s\n' "${matches[@]}"
local n
for n in "${matches[@]}"; do cmd_enable "$n" || true; done
}
cmd_disable_subdomain() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
local s="${1:-}"
[ -n "$s" ] || { echo "usage: opc-skills disable-subdomain <subdomain>" >&2; exit 2; }
local -a matches=()
mapfile -t matches < <(jq -r --arg s "$s" '
.[] | select((.subdomain // "") == $s and (.status == "active" or .status == "both")) | .folder
' "$INDEX_JSON")
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills in subdomain: $s" >&2; exit 1; }
echo "Will disable ${#matches[@]} skill(s) in subdomain '$s':"
printf ' %s\n' "${matches[@]}"
local n
for n in "${matches[@]}"; do cmd_disable "$n" || true; done
}
# bulk enable by tag — supports multiple tags (intersection: --all) or union (default)
cmd_enable_tag() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
local mode="any"
if [ "${1:-}" = "--all" ]; then mode="all"; shift; fi
[ "$#" -gt 0 ] || { echo "usage: opc-skills enable-tag [--all] <tag>..." >&2; exit 2; }
local -a matches=()
if [ "$mode" = "all" ]; then
# intersection: all given tags must appear
local jq_filter='. | map(select((.status == "parked" or .status == "both") and ([.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'
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
fi
[ "${#matches[@]}" -gt 0 ] || { echo "no parked skills with tag(s): $*" >&2; exit 1; }
echo "Will enable ${#matches[@]} skill(s) with tag(s) [$mode]: $*"
printf ' %s\n' "${matches[@]}"
local n
for n in "${matches[@]}"; do cmd_enable "$n" || true; done
}
cmd_disable_tag() {
require_dirs
ensure_jq
[ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-skills reindex" >&2; exit 1; }
local mode="any"
if [ "${1:-}" = "--all" ]; then mode="all"; shift; fi
[ "$#" -gt 0 ] || { echo "usage: opc-skills disable-tag [--all] <tag>..." >&2; exit 2; }
local -a matches=()
if [ "$mode" = "all" ]; then
local jq_filter='. | map(select((.status == "active" or .status == "both") 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'
mapfile -t matches < <(jq -r "$jq_filter" "$INDEX_JSON" --args "$@")
fi
[ "${#matches[@]}" -gt 0 ] || { echo "no active skills with tag(s): $*" >&2; exit 1; }
echo "Will disable ${#matches[@]} skill(s) with tag(s) [$mode]: $*"
printf ' %s\n' "${matches[@]}"
local n
for n in "${matches[@]}"; do cmd_disable "$n" || true; done
}
# Sync loose sibling files (e.g. _platform-mapping.md) from PARKED → ACTIVE so
# skills that use `../<file>` relative refs keep resolving after install.
sync_shared_refs() {
@@ -65,6 +208,17 @@ sync_shared_refs() {
done
}
# incremental INDEX status update (cheap; avoids full reindex per enable/disable)
update_index_status() {
local folder="$1" new_status="$2"
[ -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"
}
# enable one skill by folder name
cmd_enable() {
require_dirs
@@ -75,6 +229,7 @@ cmd_enable() {
[ -d "$dst" ] && { echo "already active: $name"; exit 0; }
cp -r "$src" "$dst"
sync_shared_refs
update_index_status "$name" "both"
echo "enabled: $name"
}
@@ -88,9 +243,11 @@ cmd_disable() {
if [ -d "$dst" ]; then
# parked copy exists; just remove active
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
}
@@ -299,40 +456,75 @@ cmd_reindex() {
import sys, os, re, json
from pathlib import Path
try:
import yaml
HAVE_YAML = True
except Exception:
HAVE_YAML = False
parked = Path(sys.argv[1])
active = Path(sys.argv[2])
out_json = Path(sys.argv[3])
out_md = Path(sys.argv[4])
def parse_frontmatter(fm: str) -> dict:
if HAVE_YAML:
try:
data = yaml.safe_load(fm)
if isinstance(data, dict): return data
except Exception:
pass
# regex fallback (best-effort, single-line scalars)
out = {}
for key in ("name", "description", "domain", "subdomain"):
m = re.search(rf'^{key}:\s*"?(.+?)"?\s*$', fm, re.MULTILINE)
if m: out[key] = m.group(1).strip()
tm = re.search(r'^tags:\s*\n((?:[ \t]*-\s*[^\n]+\n)+)', fm, re.MULTILINE)
if tm:
out["tags"] = [re.sub(r'^[ \t]*-\s*', '', l).strip().strip('"\'')
for l in tm.group(1).splitlines() if l.strip()]
return out
def read_skill(d: Path):
sm = d / "SKILL.md"
name, desc = d.name, ""
name = d.name
desc, domain, subdomain = "", "", ""
tags = []
if sm.exists():
try:
txt = sm.read_text(errors="replace")
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", txt, re.DOTALL)
if m:
fm = m.group(1)
n = re.search(r'^name:\s*"?([^"\n]+?)"?\s*$', fm, re.MULTILINE)
dsc = re.search(r'^description:\s*"?(.+?)"?\s*$', fm, re.MULTILINE | re.DOTALL)
if n: name = n.group(1).strip()
if dsc: desc = dsc.group(1).strip()
fm_data = parse_frontmatter(m.group(1))
name = str(fm_data.get("name") or d.name).strip()
desc = re.sub(r"\s+", " ", str(fm_data.get("description") or "").strip())
domain = str(fm_data.get("domain") or "").strip()
subdomain = str(fm_data.get("subdomain") or "").strip()
t = fm_data.get("tags") or []
if isinstance(t, list):
tags = [str(x).strip() for x in t if str(x).strip()]
elif isinstance(t, str):
tags = [t.strip()] if t.strip() else []
except Exception as e:
desc = f"(read error: {e})"
else:
desc = "(no SKILL.md)"
return name, desc
return {
"folder": d.name, "name": name, "description": desc,
"domain": domain, "subdomain": subdomain, "tags": tags,
}
seen = {}
def scan(base: Path, status: 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(".")):
name, desc = read_skill(d)
rec = read_skill(d)
key = d.name
if key in seen:
seen[key]["status"] = "both"
else:
seen[key] = {"folder": d.name, "name": name, "description": desc, "status": status}
rec["status"] = status
seen[key] = rec
scan(active, "active")
scan(parked, "parked")
@@ -343,22 +535,37 @@ 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"))
from collections import Counter
dom_c = Counter(i["domain"] for i in items if i["domain"])
sub_c = Counter(i["subdomain"] for i in items if i["subdomain"])
tag_c = Counter(t for i in items for t in i.get("tags", []))
top_doms = ", ".join(f"{k}:{v}" for k,v in dom_c.most_common(10)) or "(none)"
top_subs = ", ".join(f"{k}:{v}" for k,v in sub_c.most_common(10)) or "(none)"
top_tags = ", ".join(f"{k}:{v}" for k,v in tag_c.most_common(15)) or "(none)"
lines = [
"# Opencode Skills — Index",
"",
f"**{len(items)} unique skills** — active: {active_n}, parked: {parked_n}.",
f"Top domains — {top_doms}.",
f"Top subdomains — {top_subs}.",
f"Top tags — {top_tags}.",
"",
f"Active: `{active}` | Parked: `{parked}`",
"",
"See `README.md` for the `opc-skills` CLI.",
"",
"| # | Status | Folder | Description |",
"|---|--------|--------|-------------|",
"| # | Status | Domain | Subdomain | Folder | Description | Tags |",
"|---|--------|--------|-----------|--------|-------------|------|",
]
for i, it in enumerate(items, 1):
d = it["description"].replace("\n", " ").replace("|", "\\|")
if len(d) > 200: d = d[:197] + "..."
lines.append(f"| {i} | {it['status']} | `{it['folder']}` | {d} |")
if len(d) > 160: d = d[:157] + "..."
tags_str = ", ".join(it.get("tags", [])[:6])
lines.append(
f"| {i} | {it['status']} | {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})")
PY
@@ -368,23 +575,40 @@ usage() {
cat <<'USG'
opc-skills — opencode skill manager
Inspection:
status counts of active vs parked
list {active|parked|all} list skill folder names
categories prefix-based category counts
categories / cats verb-prefix category counts (eg. performing, detecting)
domains frontmatter `domain:` distribution
subdomains frontmatter `subdomain:` distribution
tags frontmatter `tags:` distribution
Single skill:
enable <folder> enable single skill (copy parked → active)
disable <folder> disable single skill (remove from active; keep parked)
disable-all [-y|--yes] disable every active skill (asks for confirmation)
enable-category <prefix> fzf multi-pick within a prefix, then enable
Bulk by axis:
enable-category <prefix> fzf multi-pick within a verb-prefix, then enable
disable-category <prefix> fzf multi-pick of ACTIVE skills with prefix, then disable
enable-domain <domain> bulk enable parked skills by frontmatter `domain:` (eg. cybersecurity)
disable-domain <domain> bulk disable from ACTIVE by `domain:`
enable-subdomain <s> bulk enable by `subdomain:` (eg. malware-analysis)
disable-subdomain <s>
enable-tag [--all] <tag>... bulk by `tags:`. default: union (any tag); --all: intersection
disable-tag [--all] <tag>...
Interactive / search:
pick fzf: choose category → multi-select → enable
disable-pick fzf: choose ACTIVE category → multi-select → disable
search [query] fzf fuzzy search across name+description (enable)
disable-search [query] fzf fuzzy search ACTIVE skills only (disable)
reindex rebuild INDEX.json / INDEX.md
reindex rebuild INDEX.json / INDEX.md (extracts domain/subdomain/tags)
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>.
- domain/subdomain/tag commands read INDEX.json — run reindex after manual changes.
Environment:
OPC_PARKED override parked dir (default: ~/Documents/opencode-skills-parked)
@@ -400,11 +624,20 @@ main() {
status) cmd_status "$@" ;;
list) cmd_list "$@" ;;
categories|cats) cmd_categories "$@" ;;
domains) cmd_domains "$@" ;;
subdomains) cmd_subdomains "$@" ;;
tags) cmd_tags "$@" ;;
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
disable-all) cmd_disable_all "$@" ;;
enable-category|enable-cat) cmd_enable_category "$@" ;;
disable-category|disable-cat) cmd_disable_category "$@" ;;
enable-domain) cmd_enable_domain "$@" ;;
disable-domain) cmd_disable_domain "$@" ;;
enable-subdomain) cmd_enable_subdomain "$@" ;;
disable-subdomain) cmd_disable_subdomain "$@" ;;
enable-tag) cmd_enable_tag "$@" ;;
disable-tag) cmd_disable_tag "$@" ;;
pick) cmd_pick "$@" ;;
disable-pick) cmd_disable_pick "$@" ;;
search) cmd_search "$@" ;;