initial: opc-agents standalone tool

This commit is contained in:
salva
2026-04-30 21:52:24 +03:00
commit ba0b744656
3 changed files with 505 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.swp
*.swo
*~
.DS_Store

69
README.md Normal file
View File

@@ -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 `<name>.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
<name>.md
INDEX.json
INDEX.md
~/.config/opencode/agents/ # only currently-enabled agents
<name>.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 <name>` | enable single agent (copy parked → active) |
| `disable <name>` | disable single agent (remove from active; keep parked) |
| `disable-all [-y\|--yes] [--keep-primary]` | disable every active agent (asks for confirmation) |
| `enable-category <prefix>` | fzf multi-pick within a prefix, then enable |
| `disable-category <prefix>` | 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 `<name>.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 <name>`.
- `--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 `<name>.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 |

432
bin/opc-agents Executable file
View File

@@ -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 <name>.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 <name>" >&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 <name>" >&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 </dev/tty || ans=""
case "$ans" in y|Y|yes|YES) ;; *) echo "cancelled"; exit 0 ;; esac
fi
local n
for n in "${actives[@]}"; do
cmd_disable "$n" || true
done
}
# enable category (by prefix)
cmd_enable_category() {
require_dirs
ensure_fzf
local prefix="${1:-}"
[ -n "$prefix" ] || { echo "usage: opc-agents enable-category <prefix>" >&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 <prefix>" >&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: <name>.md)
status counts of active vs parked (+ mode breakdown)
list {active|parked|all} list agent names
categories prefix-based category counts (PARKED)
enable <name> enable single agent (copy parked → active)
disable <name> disable single agent (remove from active; keep parked)
disable-all [-y|--yes] [--keep-primary]
disable every active agent (asks for confirmation)
enable-category <prefix> fzf multi-pick within a prefix, then enable
disable-category <prefix> 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 <name>.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 "$@"