diff --git a/README.md b/README.md index 2bffa15..3b8abfc 100644 --- a/README.md +++ b/README.md @@ -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 enable # copy parked → active +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 # copy parked → active opc-skills disable # remove from active (parked preserved) -opc-skills enable-category # fzf multi-pick within a prefix +opc-skills disable-all [-y|--yes] # disable every active skill +``` + +### Bulk by axis + +``` +opc-skills enable-category # fzf multi-pick within a verb-prefix +opc-skills disable-category +opc-skills enable-domain # bulk by frontmatter domain (eg. cybersecurity) +opc-skills disable-domain +opc-skills enable-subdomain # bulk by subdomain (eg. malware-analysis) +opc-skills disable-subdomain +opc-skills enable-tag [--all] ... # bulk by tags (default: any; --all: intersection) +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 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 diff --git a/bin/opc-skills b/bin/opc-skills index 4ef213d..037dd41 100755 --- a/bin/opc-skills +++ b/bin/opc-skills @@ -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 // "")' "$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 // "")' "$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 " >&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 " >&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 " >&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 " >&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] ..." >&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] ..." >&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 `../` 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 - status counts of active vs parked - list {active|parked|all} list skill folder names - categories prefix-based category counts - 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-category fzf multi-pick within a prefix, then enable - disable-category fzf multi-pick of ACTIVE skills with prefix, then disable - 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 +Inspection: + status counts of active vs parked + list {active|parked|all} list skill folder names + 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) + +Bulk by axis: + 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) + disable-domain bulk disable from ACTIVE by `domain:` + enable-subdomain bulk enable by `subdomain:` (eg. malware-analysis) + disable-subdomain + enable-tag [--all] ... bulk by `tags:`. default: union (any tag); --all: intersection + disable-tag [--all] ... + +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 (extracts domain/subdomain/tags) Notes: - disable* commands never delete data — they remove the active copy and keep - (or restore) the parked copy. To re-enable: opc-skills enable . + - 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. 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-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 "$@" ;;