#!/usr/bin/env python3 """Discover OpenCode agent files and list their name, mode, and description. Scans two locations recursively for ``*.md`` agent definition files: - **Project-level**: ``/.opencode/agents/`` - **User-level**: ``~/.config/opencode/agents/`` Parses YAML frontmatter (delimited by ``---``) from each file using only Python stdlib — no third-party dependencies required. When agent names collide between project and user level, the project-level definition takes precedence and the user-level duplicate is silently dropped. """ from __future__ import annotations import argparse import json import os import pathlib # --------------------------------------------------------------------------- # Frontmatter parser (stdlib only, handles block scalars) # --------------------------------------------------------------------------- def _parse_frontmatter(text: str) -> dict[str, str]: """Extract top-level scalar fields from YAML frontmatter. Handles plain values, single-line quoted strings, and ``>-`` / ``>`` block scalars. Returns a dict mapping field names to their string values. Nested structures are ignored — only the fields we care about (``description``, ``mode``) are top-level scalars. """ lines = text.splitlines() # Find the opening and closing ``---`` delimiters. delimiters: list[int] = [] for idx, line in enumerate(lines): if line.strip() == "---": delimiters.append(idx) if len(delimiters) == 2: break if len(delimiters) < 2: return {} front_lines = lines[delimiters[0] + 1 : delimiters[1]] result: dict[str, str] = {} current_key: str | None = None current_value_parts: list[str] = [] block_scalar = False # True when inside a >- / > block scalar def _flush() -> None: if current_key is not None: value = " ".join(current_value_parts).strip() # Strip surrounding quotes if present. if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]: value = value[1:-1] result[current_key] = value for line in front_lines: stripped = line.strip() # Detect a new top-level key (no leading whitespace). if not line[:1].isspace() and ":" in stripped: _flush() key, _, raw_value = stripped.partition(":") key = key.strip() raw_value = raw_value.strip() current_key = key current_value_parts = [] block_scalar = False if raw_value in (">-", ">", "|", "|-"): block_scalar = True elif raw_value: current_value_parts.append(raw_value) elif block_scalar and line[:1].isspace(): # Continuation line of a block scalar. current_value_parts.append(stripped) elif current_key is not None and line[:1].isspace() and not block_scalar: # Indented continuation of a plain/quoted value. current_value_parts.append(stripped) _flush() return result # --------------------------------------------------------------------------- # Agent discovery # --------------------------------------------------------------------------- def _discover_agents( search_dir: pathlib.Path, source_label: str ) -> list[dict[str, str]]: """Recursively find ``*.md`` files under *search_dir* and parse them.""" agents: list[dict[str, str]] = [] if not search_dir.is_dir(): return agents for md_path in sorted(search_dir.rglob("*.md")): if not md_path.is_file(): continue try: text = md_path.read_text(encoding="utf-8") except OSError: continue fields = _parse_frontmatter(text) description = fields.get("description", "") mode = fields.get("mode", "unknown") agents.append( { "name": md_path.stem, "mode": mode, "source": source_label, "description": description, } ) return agents def discover_all(cwd: pathlib.Path) -> list[dict[str, str]]: """Return the merged agent list, project-level first. When agent names collide, project-level wins. """ project_dir = cwd / ".opencode" / "agents" user_dir = pathlib.Path.home() / ".config" / "opencode" / "agents" project_agents = _discover_agents(project_dir, "project") user_agents = _discover_agents(user_dir, "user") seen_names: set[str] = {a["name"] for a in project_agents} merged = list(project_agents) for agent in user_agents: if agent["name"] not in seen_names: merged.append(agent) seen_names.add(agent["name"]) return merged # --------------------------------------------------------------------------- # Output formatting # --------------------------------------------------------------------------- def _collapse(text: str) -> str: """Collapse multi-line text into a single line.""" return " ".join(text.split()) def format_lines(agents: list[dict[str, str]]) -> str: """Format agents as one pipe-delimited line per agent.""" if not agents: return "No agents discovered." lines: list[str] = [f"Discovered {len(agents)} agent(s):"] for agent in agents: desc = _collapse(agent["description"]) mode = agent["mode"] lines.append(f"{agent['name']}|{mode}|{desc}") return "\n".join(lines) def format_json(agents: list[dict[str, str]]) -> str: """Format agents as a JSON array.""" return json.dumps(agents, indent=2) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser( description="Discover OpenCode agent files and list their name, mode, " "and description.", ) parser.add_argument( "--cwd", default=None, help="Working directory to search for .opencode/agents/ " "(default: current directory)", ) parser.add_argument( "--json", dest="output_json", action="store_true", help="Output as JSON instead of pipe-delimited lines", ) args = parser.parse_args() cwd = pathlib.Path(args.cwd) if args.cwd else pathlib.Path(os.getcwd()) agents = discover_all(cwd) if args.output_json: print(format_json(agents)) else: print(format_lines(agents)) if __name__ == "__main__": main()