From ba0b744656aef225f56bc6078a81e8f75e2191b2 Mon Sep 17 00:00:00 2001 From: salva Date: Thu, 30 Apr 2026 21:52:24 +0300 Subject: [PATCH] initial: opc-agents standalone tool --- .gitignore | 4 + README.md | 69 ++++++++ bin/opc-agents | 432 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 505 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/opc-agents diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44fa520 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.swp +*.swo +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ca7c73 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# opc-agents + +Small shell utility for toggling opencode **agents** between a curated "active" set and a full "parked" catalog. Keeps `~/.config/opencode/agents/` small and intentional while preserving the full agent library offline. + +Companion to [`opc-skills`](https://gitea.taygun.net.tr/salvacybersec/opc-skills) — same UX, file-based unit (each agent is a single `.md` file rather than a folder). + +## Why + +opencode injects every agent's frontmatter (`description`, `mode`, …) into the calling agent's tool registry. With 100+ agents the registry alone burns ~280K tokens of context before the user even types a prompt. Park the ones you don't need this week. + +## Install + +```bash +ln -s ~/Documents/opc-agents/bin/opc-agents ~/.local/bin/opc-agents +``` + +Ensure `~/.local/bin` is on your `PATH`. + +## Layout it expects + +``` +~/Documents/opencode-agents-parked/ # full catalog — untracked data dir + .md + INDEX.json + INDEX.md +~/.config/opencode/agents/ # only currently-enabled agents + .md +``` + +Override via env: +- `OPC_AGENTS_PARKED` — parked catalog root (default `~/Documents/opencode-agents-parked`) +- `OPC_AGENTS_ACTIVE` — active agents root (default `~/.config/opencode/agents`) + +## Commands + +| | | +|---|---| +| `status` | counts of active vs parked + mode breakdown | +| `list {active\|parked\|all}` | list agent names | +| `categories` / `cats` | prefix-based category counts (PARKED) | +| `enable ` | enable single agent (copy parked → active) | +| `disable ` | disable single agent (remove from active; keep parked) | +| `disable-all [-y\|--yes] [--keep-primary]` | disable every active agent (asks for confirmation) | +| `enable-category ` | fzf multi-pick within a prefix, then enable | +| `disable-category ` | fzf multi-pick of ACTIVE agents 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 agents only (disable) | +| `reindex` | rebuild `INDEX.json` / `INDEX.md` | + +`fzf` is required for the interactive pickers; `jq` for the search variants; `python3` for `reindex`. + +## Notes + +- Each agent is a single `.md` file with YAML frontmatter (typically `description`, `mode`, `temperature`, …). +- `disable*` commands never delete data — they remove the active copy and keep (or restore) the parked copy. Re-enable with `opc-agents enable `. +- `--keep-primary` on `disable-all` skips agents with `mode: primary` (those are loaded as top-level personas in opencode). +- Common agent prefixes seen in personas: `frodo-`, `marshal-`, `sentinel-`, `bastion-`, `neo-`, `oracle-`, `warden-`, `polyglot-`, etc. + +## Differences from `opc-skills` + +| | `opc-skills` | `opc-agents` | +|---|---|---| +| Unit | directory containing `SKILL.md` | single `.md` file | +| Frontmatter fields used | `name`, `description` | `description`, `mode` | +| Shared refs sync | yes (`_platform-mapping.md`) | n/a | +| `disable-all` flags | `-y` | `-y`, `--keep-primary` | +| Status breakdown | active/parked | + primary/subagent counts | diff --git a/bin/opc-agents b/bin/opc-agents new file mode 100755 index 0000000..a3829d1 --- /dev/null +++ b/bin/opc-agents @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +# opc-agents — opencode agent enable/disable manager +# Parked: ~/Documents/opencode-agents-parked +# Active: ~/.config/opencode/agents +# +# Mirrors opc-skills, but each unit is a single .md file (not a folder). + +set -euo pipefail + +PARKED="${OPC_AGENTS_PARKED:-$HOME/Documents/opencode-agents-parked}" +ACTIVE="${OPC_AGENTS_ACTIVE:-$HOME/.config/opencode/agents}" +INDEX_JSON="$PARKED/INDEX.json" +INDEX_MD="$PARKED/INDEX.md" + +require_dirs() { + [ -d "$PARKED" ] || { echo "parked dir missing: $PARKED" >&2; exit 1; } + mkdir -p "$ACTIVE" +} + +# list agent base-names (without .md) in a given dir +agent_names_in() { + local base="$1" + [ -d "$base" ] || return 0 + find "$base" -mindepth 1 -maxdepth 1 -type f -name '*.md' \ + ! -name 'INDEX.md' ! -name 'README.md' -printf '%f\n' \ + | sed 's/\.md$//' | sort +} + +cmd_status() { + require_dirs + local active parked + active=$(agent_names_in "$ACTIVE" | wc -l) + parked=$(agent_names_in "$PARKED" | wc -l) + printf "active : %5d (%s)\n" "$active" "$ACTIVE" + printf "parked : %5d (%s)\n" "$parked" "$PARKED" + + # mode breakdown for active (primary vs subagent) — primary stays in main context + if [ "$active" -gt 0 ]; then + local primary subagent other + primary=$(grep -l '^mode: primary' "$ACTIVE"/*.md 2>/dev/null | wc -l) + subagent=$(grep -l '^mode: subagent' "$ACTIVE"/*.md 2>/dev/null | wc -l) + other=$((active - primary - subagent)) + echo + printf " primary : %5d\n" "$primary" + printf " subagent : %5d\n" "$subagent" + [ "$other" -gt 0 ] && printf " (other) : %5d\n" "$other" + fi +} + +cmd_list() { + require_dirs + local which="${1:-parked}" + case "$which" in + active) agent_names_in "$ACTIVE" ;; + parked) agent_names_in "$PARKED" ;; + all) { agent_names_in "$ACTIVE"; agent_names_in "$PARKED"; } | sort -u ;; + *) echo "usage: opc-agents list {active|parked|all}" >&2; exit 2 ;; + esac +} + +cmd_categories() { + require_dirs + agent_names_in "$PARKED" \ + | awk -F'-' '{print $1}' \ + | sort | uniq -c | sort -rn +} + +ensure_fzf() { + command -v fzf >/dev/null || { echo "fzf is required for this command" >&2; exit 1; } +} + +# enable one agent by base-name (no .md) +cmd_enable() { + require_dirs + local name="${1:-}" + [ -n "$name" ] || { echo "usage: opc-agents enable " >&2; exit 2; } + name="${name%.md}" + local src="$PARKED/$name.md" dst="$ACTIVE/$name.md" + [ -f "$src" ] || { echo "not parked: $name" >&2; exit 1; } + [ -f "$dst" ] && { echo "already active: $name"; exit 0; } + cp "$src" "$dst" + echo "enabled: $name" +} + +# disable one active agent (keep parked copy) +cmd_disable() { + require_dirs + local name="${1:-}" + [ -n "$name" ] || { echo "usage: opc-agents disable " >&2; exit 2; } + name="${name%.md}" + local src="$ACTIVE/$name.md" dst="$PARKED/$name.md" + [ -f "$src" ] || { echo "not active: $name" >&2; exit 1; } + if [ -f "$dst" ]; then + rm -f "$src" + echo "removed active copy (parked version kept): $name" + else + mv "$src" "$dst" + echo "disabled (moved to parked): $name" + fi +} + +# disable all active agents +cmd_disable_all() { + require_dirs + local force=0 keep_primary=0 a + for a in "$@"; do + case "$a" in + -y|--yes) force=1 ;; + --keep-primary) keep_primary=1 ;; + *) echo "unknown flag: $a" >&2; exit 2 ;; + esac + done + local -a actives + if [ "$keep_primary" -eq 1 ]; then + mapfile -t actives < <( + agent_names_in "$ACTIVE" | while read -r n; do + grep -q '^mode: primary' "$ACTIVE/$n.md" 2>/dev/null || echo "$n" + done + ) + else + mapfile -t actives < <(agent_names_in "$ACTIVE") + fi + [ "${#actives[@]}" -gt 0 ] || { echo "no active agents to disable"; exit 0; } + echo "Will disable ${#actives[@]} active agent(s):" + printf ' %s\n' "${actives[@]}" + if [ "$force" -ne 1 ]; then + printf "Proceed? [y/N] " + read -r ans " >&2; exit 2; } + local -a matches + mapfile -t matches < <(agent_names_in "$PARKED" | awk -v p="$prefix" '$0 ~ "^" p "(-|$)"') + [ "${#matches[@]}" -gt 0 ] || { echo "no parked agents match prefix: $prefix" >&2; exit 1; } + echo "Matches (${#matches[@]}):" + local selection + selection=$(printf '%s\n' "${matches[@]}" | fzf --multi --height=70% \ + --prompt="enable-category $prefix > " \ + --header="TAB: toggle | ENTER: confirm | ESC: cancel" \ + --preview="sed -n '1,40p' \"$PARKED/{}.md\" 2>/dev/null" \ + --preview-window=right:60%:wrap) + [ -n "$selection" ] || { echo "cancelled"; exit 0; } + local n + while IFS= read -r n; do + [ -z "$n" ] && continue + cmd_enable "$n" || true + done <<< "$selection" +} + +# disable category (by prefix, only ACTIVE) +cmd_disable_category() { + require_dirs + ensure_fzf + local prefix="${1:-}" + [ -n "$prefix" ] || { echo "usage: opc-agents disable-category " >&2; exit 2; } + local -a matches + mapfile -t matches < <(agent_names_in "$ACTIVE" | awk -v p="$prefix" '$0 ~ "^" p "(-|$)"') + [ "${#matches[@]}" -gt 0 ] || { echo "no active agents match prefix: $prefix" >&2; exit 1; } + echo "Active matches (${#matches[@]}):" + local selection + selection=$(printf '%s\n' "${matches[@]}" | fzf --multi --height=70% \ + --prompt="disable-category $prefix > " \ + --header="TAB: toggle | ENTER: confirm | ESC: cancel" \ + --preview="sed -n '1,40p' \"$ACTIVE/{}.md\" 2>/dev/null" \ + --preview-window=right:60%:wrap) + [ -n "$selection" ] || { echo "cancelled"; exit 0; } + local n + while IFS= read -r n; do + [ -z "$n" ] && continue + cmd_disable "$n" || true + done <<< "$selection" +} + +# interactive pick: parked category → multi-select → enable +cmd_pick() { + require_dirs + ensure_fzf + local category + category=$(cmd_categories | fzf --prompt="category > " --height=50% \ + --header="Pick a category (first column = count)" \ + | awk '{print $2}') + [ -n "$category" ] || { echo "cancelled"; exit 0; } + local selection + selection=$(agent_names_in "$PARKED" \ + | awk -v p="$category" '$0 ~ "^" p "(-|$)"' \ + | fzf --multi --height=80% \ + --prompt="$category > " \ + --header="TAB: toggle | ENTER: enable selected" \ + --preview="sed -n '1,40p' \"$PARKED/{}.md\" 2>/dev/null" \ + --preview-window=right:60%:wrap) + [ -n "$selection" ] || { echo "cancelled"; exit 0; } + local n + while IFS= read -r n; do + [ -z "$n" ] && continue + cmd_enable "$n" || true + done <<< "$selection" +} + +# interactive pick: ACTIVE category → multi-select → disable +cmd_disable_pick() { + require_dirs + ensure_fzf + local active_count + active_count=$(agent_names_in "$ACTIVE" | wc -l) + [ "$active_count" -gt 0 ] || { echo "no active agents"; exit 0; } + local cats category + cats=$(agent_names_in "$ACTIVE" | awk -F'-' '{print $1}' | sort | uniq -c | sort -rn) + category=$(printf '%s\n' "$cats" | fzf --prompt="active-category > " --height=50% \ + --header="Pick a category of ACTIVE agents to disable" \ + | awk '{print $2}') + [ -n "$category" ] || { echo "cancelled"; exit 0; } + local selection + selection=$(agent_names_in "$ACTIVE" \ + | awk -v p="$category" '$0 ~ "^" p "(-|$)"' \ + | fzf --multi --height=80% \ + --prompt="disable $category > " \ + --header="TAB: toggle | ENTER: disable selected" \ + --preview="sed -n '1,40p' \"$ACTIVE/{}.md\" 2>/dev/null" \ + --preview-window=right:60%:wrap) + [ -n "$selection" ] || { echo "cancelled"; exit 0; } + local n + while IFS= read -r n; do + [ -z "$n" ] && continue + cmd_disable "$n" || true + done <<< "$selection" +} + +# fzf search (parked) by name+description (uses INDEX.json) +cmd_search() { + require_dirs + ensure_fzf + command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; } + [ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-agents reindex" >&2; exit 1; } + local query="${1:-}" + local tmp; tmp=$(mktemp) + jq -r '.[] | "\(.name)\t\(.mode)\t\(.description)"' "$INDEX_JSON" > "$tmp" + local selection + if [ -n "$query" ]; then + selection=$(fzf --query="$query" --multi --height=80% \ + --delimiter='\t' --with-nth=1,2,3 \ + --prompt="search > " \ + --header="TAB: toggle | ENTER: enable selected" \ + --preview="sed -n '1,40p' \"$PARKED/{1}.md\" 2>/dev/null || echo '(not parked — maybe already active)'" \ + --preview-window=right:60%:wrap < "$tmp") + else + selection=$(fzf --multi --height=80% \ + --delimiter='\t' --with-nth=1,2,3 \ + --prompt="search > " \ + --header="TAB: toggle | ENTER: enable selected" \ + --preview="sed -n '1,40p' \"$PARKED/{1}.md\" 2>/dev/null || echo '(not parked — maybe already active)'" \ + --preview-window=right:60%:wrap < "$tmp") + fi + rm -f "$tmp" + [ -n "$selection" ] || { echo "cancelled"; exit 0; } + local name + while IFS=$'\t' read -r name _ _; do + [ -z "$name" ] && continue + cmd_enable "$name" || true + done <<< "$selection" +} + +# fzf search (ACTIVE only) by name+description +cmd_disable_search() { + require_dirs + ensure_fzf + command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; } + [ -f "$INDEX_JSON" ] || { echo "INDEX.json missing — run: opc-agents reindex" >&2; exit 1; } + local query="${1:-}" + local tmp; tmp=$(mktemp) + jq -r '.[] | select(.status=="active" or .status=="both") | "\(.name)\t\(.mode)\t\(.description)"' "$INDEX_JSON" > "$tmp" + [ -s "$tmp" ] || { rm -f "$tmp"; echo "no active agents indexed (try: opc-agents reindex)"; exit 0; } + local selection + if [ -n "$query" ]; then + selection=$(fzf --query="$query" --multi --height=80% \ + --delimiter='\t' --with-nth=1,2,3 \ + --prompt="disable-search > " \ + --header="TAB: toggle | ENTER: disable selected" \ + --preview="sed -n '1,40p' \"$ACTIVE/{1}.md\" 2>/dev/null" \ + --preview-window=right:60%:wrap < "$tmp") + else + selection=$(fzf --multi --height=80% \ + --delimiter='\t' --with-nth=1,2,3 \ + --prompt="disable-search > " \ + --header="TAB: toggle | ENTER: disable selected" \ + --preview="sed -n '1,40p' \"$ACTIVE/{1}.md\" 2>/dev/null" \ + --preview-window=right:60%:wrap < "$tmp") + fi + rm -f "$tmp" + [ -n "$selection" ] || { echo "cancelled"; exit 0; } + local name + while IFS=$'\t' read -r name _ _; do + [ -z "$name" ] && continue + cmd_disable "$name" || true + done <<< "$selection" +} + +# rebuild INDEX.json + INDEX.md by scanning parked + active +cmd_reindex() { + require_dirs + python3 - "$PARKED" "$ACTIVE" "$INDEX_JSON" "$INDEX_MD" <<'PY' +import sys, re, json +from pathlib import Path + +parked = Path(sys.argv[1]) +active = Path(sys.argv[2]) +out_json = Path(sys.argv[3]) +out_md = Path(sys.argv[4]) + +def read_agent(p: Path): + name = p.stem + desc = "" + mode = "" + try: + txt = p.read_text(errors="replace") + m = re.match(r"^---\s*\n(.*?)\n---\s*\n", txt, re.DOTALL) + if m: + fm = m.group(1) + d = re.search(r'^description:\s*"?(.+?)"?\s*$', fm, re.MULTILINE | re.DOTALL) + mo = re.search(r'^mode:\s*"?([^"\n]+?)"?\s*$', fm, re.MULTILINE) + if d: desc = d.group(1).strip().splitlines()[0] + if mo: mode = mo.group(1).strip() + except Exception as e: + desc = f"(read error: {e})" + return name, desc, mode + +seen = {} +def scan(base: Path, status: str): + if not base.exists(): return + for p in sorted(base.glob("*.md")): + if p.name in ("INDEX.md", "README.md"): continue + n, d, m = read_agent(p) + if n in seen: + seen[n]["status"] = "both" + else: + seen[n] = {"name": n, "mode": m or "?", "description": d, "status": status} + +scan(active, "active") +scan(parked, "parked") + +items = sorted(seen.values(), key=lambda x: x["name"]) +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")) +prim = sum(1 for i in items if i["mode"] == "primary") +sub = sum(1 for i in items if i["mode"] == "subagent") + +lines = [ + "# Opencode Agents — Index", + "", + f"**{len(items)} unique agents** — active: {active_n}, parked: {parked_n}.", + f"Modes — primary: {prim}, subagent: {sub}.", + "", + f"Active: `{active}` | Parked: `{parked}`", + "", + "| # | Status | Mode | Name | Description |", + "|---|--------|------|------|-------------|", +] +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['mode']} | `{it['name']}` | {d} |") +out_md.write_text("\n".join(lines) + "\n") +print(f"reindexed: {len(items)} agents (active={active_n}, parked={parked_n}, primary={prim}, subagent={sub})") +PY +} + +usage() { + cat <<'USG' +opc-agents — opencode agent manager (file-based: .md) + + status counts of active vs parked (+ mode breakdown) + list {active|parked|all} list agent names + categories prefix-based category counts (PARKED) + enable enable single agent (copy parked → active) + disable disable single agent (remove from active; keep parked) + disable-all [-y|--yes] [--keep-primary] + disable every active agent (asks for confirmation) + enable-category fzf multi-pick within a prefix, then enable + disable-category fzf multi-pick of ACTIVE agents 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 agents only (disable) + reindex rebuild INDEX.json / INDEX.md + +Notes: + - Each agent is a single .md file (frontmatter + body). + - disable* never deletes data — removes active copy and keeps (or restores) parked. + - --keep-primary on disable-all skips agents with `mode: primary` (those stay loaded). + +Environment: + OPC_AGENTS_PARKED override parked dir (default: ~/Documents/opencode-agents-parked) + OPC_AGENTS_ACTIVE override active dir (default: ~/.config/opencode/agents) +USG +} + +main() { + local cmd="${1:-}" + [ -n "$cmd" ] || { usage; exit 2; } + shift || true + case "$cmd" in + status) cmd_status "$@" ;; + list) cmd_list "$@" ;; + categories|cats) cmd_categories "$@" ;; + 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 "$@" ;; + pick) cmd_pick "$@" ;; + disable-pick) cmd_disable_pick "$@" ;; + search) cmd_search "$@" ;; + disable-search) cmd_disable_search "$@" ;; + reindex) cmd_reindex "$@" ;; + -h|--help|help) usage ;; + *) echo "unknown command: $cmd" >&2; usage; exit 2 ;; + esac +} + +main "$@"