0408c27e6e
Includes agents, skills, scripts, container launch scripts, and project configuration files (opencode.json, crush.json, Dockerfile). Add repo reference for skill container Add workspace for persist container, used for testing cli aa
220 lines
6.5 KiB
Python
Executable File
220 lines
6.5 KiB
Python
Executable File
#!/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**: ``<cwd>/.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()
|