diff --git a/README.md b/README.md index 8b270c6..abaaaad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 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). +**Multi-target** agent manager for **opencode** + **Claude Code**. Per-target parked sources (different formats), shared CLI surface. Companion to [`opc-skills`](https://gitea.taygun.net.tr/salvacybersec/opc-skills). ## 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. +opencode injects every agent's frontmatter into the calling agent's tool registry. With 100+ agents the registry alone burns ~280K tokens of context before the user types a prompt. Park the ones you don't need this week. + +opencode and Claude Code use **different agent frontmatter formats** (opencode: `permission: { … }`, claude: `tools: Read, Glob, …`), so opc-agents keeps a separate parked source per target. ## Install @@ -19,17 +19,29 @@ 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 +~/Documents/personas/agents-opencode-archive/ # parked, opencode format + .md (frontmatter: mode, permission, …) + INDEX.json + INDEX.md (canonical index — also tracks claude side) + +~/Documents/personas/agents-claude-archive/ # parked, claude format + .md (frontmatter: tools: Read, Glob, …) + +~/.config/opencode/agents/ # active, opencode target +~/.claude/agents/ # active, claude target ``` +(`~/Documents/opencode-agents-parked` is symlinked to `personas/agents-opencode-archive` for backward compat.) + Override via env: -- `OPC_AGENTS_PARKED` — parked catalog root (default `~/Documents/opencode-agents-parked`) -- `OPC_AGENTS_ACTIVE` — active agents root (default `~/.config/opencode/agents`) +- `OPC_AGENTS_PARKED` — opencode parked source (default `personas/agents-opencode-archive`) +- `OPC_AGENTS_CLAUDE_PARKED` — claude parked source (default `personas/agents-claude-archive`) +- `OPC_AGENTS_ACTIVE` — opencode active dir (default `~/.config/opencode/agents`) +- `OPC_AGENTS_CLAUDE_ACTIVE` — claude active dir (default `~/.claude/agents`) +- `OPC_AGENTS_TARGETS` — comma-separated default targets (default `opencode,claude`) + +## Targets + +`enable`, `disable`, and `disable-all` accept `--target` / `-t` (`opencode`, `claude`, `both`/`all`). Default is `opencode,claude`. An agent missing in a target's parked source is skipped with a clear message — there is **no automatic format conversion**. ## Commands diff --git a/bin/opc-agents b/bin/opc-agents index 7a63b69..dad8d4e 100755 --- a/bin/opc-agents +++ b/bin/opc-agents @@ -1,22 +1,80 @@ #!/usr/bin/env bash -# opc-agents — opencode agent enable/disable manager -# Parked: ~/Documents/opencode-agents-parked -# Active: ~/.config/opencode/agents +# opc-agents — multi-target agent enable/disable manager (opencode + claude) # -# Mirrors opc-skills, but each unit is a single .md file (not a folder). +# Parked sources (per-target — formats differ): +# opencode → ~/Documents/personas/agents-opencode-archive (permission: { ... }) +# claude → ~/Documents/personas/agents-claude-archive (tools: Read, Glob, ...) +# +# Active targets: +# opencode → ~/.config/opencode/agents +# claude → ~/.claude/agents +# +# Default targets: opencode,claude (override via OPC_AGENTS_TARGETS or --target) 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" +# Backward compat: OPC_AGENTS_PARKED still works for opencode (legacy) +PARKED_OPENCODE="${OPC_AGENTS_PARKED:-$HOME/Documents/personas/agents-opencode-archive}" +PARKED_CLAUDE="${OPC_AGENTS_CLAUDE_PARKED:-$HOME/Documents/personas/agents-claude-archive}" +ACTIVE_OPENCODE="${OPC_AGENTS_ACTIVE:-$HOME/.config/opencode/agents}" +ACTIVE_CLAUDE="${OPC_AGENTS_CLAUDE_ACTIVE:-$HOME/.claude/agents}" + +# Index lives under opencode-archive (canonical) but tracks BOTH targets +INDEX_JSON="$PARKED_OPENCODE/INDEX.json" +INDEX_MD="$PARKED_OPENCODE/INDEX.md" + +DEFAULT_TARGETS="${OPC_AGENTS_TARGETS:-opencode,claude}" + +# resolve target → parked dir / active dir +target_parked() { + case "$1" in + opencode) printf '%s\n' "$PARKED_OPENCODE" ;; + claude) printf '%s\n' "$PARKED_CLAUDE" ;; + *) echo "unknown target: $1" >&2; return 1 ;; + esac +} +target_active() { + case "$1" in + opencode) printf '%s\n' "$ACTIVE_OPENCODE" ;; + claude) printf '%s\n' "$ACTIVE_CLAUDE" ;; + *) echo "unknown target: $1" >&2; return 1 ;; + esac +} + +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_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 +} require_dirs() { - [ -d "$PARKED" ] || { echo "parked dir missing: $PARKED" >&2; exit 1; } - mkdir -p "$ACTIVE" + [ -d "$PARKED_OPENCODE" ] || mkdir -p "$PARKED_OPENCODE" + [ -d "$PARKED_CLAUDE" ] || mkdir -p "$PARKED_CLAUDE" + mkdir -p "$ACTIVE_OPENCODE" "$ACTIVE_CLAUDE" } +# legacy aliases for backward compat +PARKED="$PARKED_OPENCODE" +ACTIVE="$ACTIVE_OPENCODE" + # list agent base-names (without .md) in a given dir agent_names_in() { local base="$1" @@ -28,19 +86,28 @@ agent_names_in() { 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" + echo "Parked sources (per-target — different formats):" + printf " %-10s %5d (%s)\n" "opencode" "$(agent_names_in "$PARKED_OPENCODE" | wc -l)" "$PARKED_OPENCODE" + printf " %-10s %5d (%s)\n" "claude" "$(agent_names_in "$PARKED_CLAUDE" | wc -l)" "$PARKED_CLAUDE" - # mode breakdown for active (primary vs subagent) — primary stays in main context - if [ "$active" -gt 0 ]; then + echo "" + echo "Active targets:" + local t base n + for t in opencode claude; do + base=$(target_active "$t") + n=$(agent_names_in "$base" | wc -l) + printf " %-10s %5d (%s)\n" "$t" "$n" "$base" + done + + # mode breakdown (opencode-style frontmatter; claude format has no `mode:`) + local active_oc; active_oc=$(agent_names_in "$ACTIVE_OPENCODE" | wc -l) + if [ "$active_oc" -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)) + primary=$(grep -l '^mode: primary' "$ACTIVE_OPENCODE"/*.md 2>/dev/null | wc -l) + subagent=$(grep -l '^mode: subagent' "$ACTIVE_OPENCODE"/*.md 2>/dev/null | wc -l) + other=$((active_oc - primary - subagent)) echo + echo "opencode mode breakdown:" printf " primary : %5d\n" "$primary" printf " subagent : %5d\n" "$subagent" [ "$other" -gt 0 ] && printf " (other) : %5d\n" "$other" @@ -166,38 +233,62 @@ cmd_disable_domain() { for n in "${matches[@]}"; do cmd_disable "$n" || true; done } -# enable one agent by base-name (no .md) +# enable one agent in selected targets +# usage: cmd_enable [--target T] cmd_enable() { + parse_target_flag "$@" + set -- "${REMAINING_ARGS[@]}" require_dirs local name="${1:-}" - [ -n "$name" ] || { echo "usage: opc-agents enable " >&2; exit 2; } + [ -n "$name" ] || { echo "usage: opc-agents enable [--target opencode|claude|both] " >&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" + local t pdir adir src dst + IFS=',' read -ra tgts <<< "$PARSED_TARGETS" + for t in "${tgts[@]}"; do + pdir=$(target_parked "$t") || continue + adir=$(target_active "$t") || continue + src="$pdir/$name.md" + dst="$adir/$name.md" + if [ ! -f "$src" ]; then + echo "[$t] not parked (no source for this target): $name" + continue + fi + if [ -f "$dst" ]; then + echo "[$t] already active: $name" + else + cp "$src" "$dst" + echo "[$t] enabled: $name" + fi + done } -# disable one active agent (keep parked copy) +# disable one agent in selected targets (keeps parked source untouched) cmd_disable() { + parse_target_flag "$@" + set -- "${REMAINING_ARGS[@]}" require_dirs local name="${1:-}" - [ -n "$name" ] || { echo "usage: opc-agents disable " >&2; exit 2; } + [ -n "$name" ] || { echo "usage: opc-agents disable [--target opencode|claude|both] " >&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 + local t adir src + IFS=',' read -ra tgts <<< "$PARSED_TARGETS" + for t in "${tgts[@]}"; do + adir=$(target_active "$t") || continue + src="$adir/$name.md" + if [ ! -f "$src" ]; then + echo "[$t] not active: $name" + continue + fi rm -f "$src" - echo "removed active copy (parked version kept): $name" - else - mv "$src" "$dst" - echo "disabled (moved to parked): $name" - fi + echo "[$t] disabled: $name" + done } -# disable all active agents +# disable all active agents across selected targets +# usage: cmd_disable_all [--target T] [-y|--yes] [--keep-primary] cmd_disable_all() { + parse_target_flag "$@" + set -- "${REMAINING_ARGS[@]}" require_dirs local force=0 keep_primary=0 a for a in "$@"; do @@ -207,27 +298,33 @@ cmd_disable_all() { *) 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[@]}" + IFS=',' read -ra tgts <<< "$PARSED_TARGETS" + # Collect (target, name) pairs that should be disabled + local -a pairs=() + local t adir n + for t in "${tgts[@]}"; do + adir=$(target_active "$t") || continue + while IFS= read -r n; do + [ -z "$n" ] && continue + if [ "$keep_primary" -eq 1 ]; then + # only opencode has 'mode: primary' frontmatter; claude format has no mode field → never primary + if grep -q '^mode: primary' "$adir/$n.md" 2>/dev/null; then continue; fi + fi + pairs+=("$t:$n") + done < <(agent_names_in "$adir") + done + [ "${#pairs[@]}" -gt 0 ] || { echo "no active agents to disable in [${tgts[*]}]"; exit 0; } + echo "Will disable ${#pairs[@]} (target:agent) entries:" + printf ' %s\n' "${pairs[@]}" if [ "$force" -ne 1 ]; then printf "Proceed? [y/N] " read -r ans 160: d = d[:157] + "..." + if len(d) > 140: d = d[:137] + "..." + pk = ",".join(t for t in ("opencode","claude") if it["parked"][t]) or "-" + ac = ",".join(it.get("active", [])) or "-" lines.append( - f"| {i} | {it['status']} | {it['mode']} | {it['persona']} | " + f"| {i} | {pk} | {ac} | {it['mode']} | {it['persona']} | " f"{it['variant'] or '-'} | {it['domain'] or '-'} | `{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})") +print(f"reindexed: {len(items)} agents " + f"(parked oc={n_oc_park} cl={n_cl_park} both={both_park}, " + f"active oc={in_oc} cl={in_cl}, primary={prim}, subagent={sub})") PY } usage() { cat <<'USG' -opc-agents — opencode agent manager (file-based: .md) +opc-agents — multi-target agent manager (opencode + claude) - status counts of active vs parked (+ mode breakdown) - list {active|parked|all} list agent names - categories prefix-based base-persona counts (PARKED) - variants suffix-based variant counts (PARKED) — eg. salva, iran - 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) +Targets: opencode, claude (or "both"/"all"; default = opencode,claude) +Use --target / -t with enable/disable/disable-all to scope the operation. + + status per-target counts (parked + active) + mode breakdown + list {active|parked|all} list agent names (active = opencode set) + categories prefix-based base-persona counts (PARKED opencode) + variants suffix-based variant counts (PARKED opencode) + enable [--target T] copy parked → active in selected targets + disable [--target T] remove from active in selected targets + disable-all [--target T] [-y|--yes] [--keep-primary] + disable every active agent across targets enable-category fzf multi-pick within a base-persona prefix, then enable disable-category fzf multi-pick of ACTIVE agents with prefix, then disable - enable-variant bulk enable all *-.md from PARKED (eg. salva) - disable-variant bulk disable all *-.md from ACTIVE + enable-variant bulk enable all *-.md (eg. salva) + disable-variant bulk disable all *-.md enable-domain bulk enable agents whose `Domain: ` matches (live scan) - disable-domain bulk disable from ACTIVE matching domain (live scan) + disable-domain bulk disable from ACTIVE matching domain pick fzf: choose category → multi-select → enable disable-pick fzf: choose ACTIVE category → multi-select → disable - search [query] fzf fuzzy search (name+mode+domain+variant+description) + search [query] fzf fuzzy search across all index fields disable-search [query] fzf fuzzy search ACTIVE agents only (disable) - reindex rebuild INDEX.json / INDEX.md (extracts persona/variant/domain) + reindex rebuild INDEX.json / INDEX.md (per-target parked + active) 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). + - Agent format differs between targets: opencode uses `permission: {...}`, + claude uses `tools: Read, Glob, ...`. Each target reads from its own parked dir. + - disable* never deletes parked sources. + - --keep-primary skips agents with `mode: primary` (opencode-format only). + - Single source-of-truth: ~/Documents/personas/agents-{opencode,claude}-archive Environment: - OPC_AGENTS_PARKED override parked dir (default: ~/Documents/opencode-agents-parked) - OPC_AGENTS_ACTIVE override active dir (default: ~/.config/opencode/agents) + OPC_AGENTS_PARKED opencode parked (default: personas/agents-opencode-archive) + OPC_AGENTS_CLAUDE_PARKED claude parked (default: personas/agents-claude-archive) + OPC_AGENTS_ACTIVE opencode active (default: ~/.config/opencode/agents) + OPC_AGENTS_CLAUDE_ACTIVE claude active (default: ~/.claude/agents) + OPC_AGENTS_TARGETS default targets when --target omitted (default: opencode,claude) USG }