wshobson--agents
98862b56d8
* feat: AGENTS.md canonical context + OpenAI harness-engineering layout Promote AGENTS.md to the committed cross-harness context file (per the agents.md convention and OpenAI's harness-engineering blog). Harness- specific files become thin redirects: - AGENTS.md — canonical, committed (~74 lines, table-of-contents) - CLAUDE.md — `@AGENTS.md` import + Claude-specific addenda - GEMINI.md — Gemini-specific setup only - .gemini/settings.json — redirects Gemini CLI's context to read AGENTS.md - ARCHITECTURE.md — new at root, top-level architectural map - gemini-extension.json — bumps version to 1.7.0, sets contextFileName: AGENTS.md - .gitignore — drops the AGENTS.md entry (file is now committed) Harness support verified: - Codex CLI reads AGENTS.md natively (root → cwd walk, 32 KiB cap) - Cursor 2.5+ reads AGENTS.md natively - OpenCode reads AGENTS.md natively (wins over CLAUDE.md if both exist) - Claude Code: `CLAUDE.md` first line is `@AGENTS.md` (Anthropic's documented interop pattern) - Gemini CLI: `.gemini/settings.json` context.fileName redirect (Gemini doesn't support @-imports) Codex adapter no longer generates AGENTS.md — `emit_global` instead validates the committed file fits Codex's 32 KiB cap and the 150-line table-of-contents convention. Tests updated. Clean-output target no longer touches AGENTS.md. ## Auxiliary files updated for multi-harness reality - `.github/ISSUE_TEMPLATE/bug_report.yml` — dropdown for harness + component path; renames "subagent" → "plugin/agent/skill/command" - `.github/ISSUE_TEMPLATE/feature_request.yml` — scope dropdown covers framework / harness / tooling / docs / CI in addition to components - `.github/ISSUE_TEMPLATE/new_subagent.yml` — relabeled "New Component Proposal" with component-type dropdown (plugin/agent/skill/command/ harness adapter) and cross-harness portability field - `.github/ISSUE_TEMPLATE/config.yml` — links to AGENTS.md, authoring guide, per-harness docs; updated Contributing link to root - `.github/CONTRIBUTING.md` — thin pointer to canonical root CONTRIBUTING.md - `.github/PULL_REQUEST_TEMPLATE.md` — new; scope + affected-harness checklists, test-plan checklist, portability-notes section - CONTRIBUTING.md (root) — updated to reference AGENTS.md / ARCHITECTURE.md - gemini-extension.json — version 1.6.0 → 1.7.0, count fixes, redirects to AGENTS.md as contextFileName ## Code-quality CI New `.github/workflows/code-quality.yml` with three jobs: - `python-lint` — `ruff check`, `ruff format --check`, `ty check` on the adapter framework + plugin-eval. yt-design-extractor.py legacy code excluded. - `markdown-lint` — markdownlint-cli2 against README, AGENTS, ARCHITECTURE, CLAUDE, top-level guides, and docs/. Config in `.markdownlint.json`. - `json-lint` — validates every JSON / TOML / YAML in the repo (excluding generated trees). Required ty environment config added to plugin-eval/pyproject.toml so `tools.adapters.*` resolves from outside the package. Fixed one ty error in `tools/adapters/base.py:HarnessAdapter.capabilities` (return-type annotation didn't match the `Capability` dataclass returned). Fixed two ruff SIM108 ternary suggestions in codex.py and doc_gardener.py. ruff format applied across all in-scope files (formatting-only diffs). ## Tests + verification - 387 pytest tests pass (1 new test for the AGENTS.md validate-don't-overwrite behavior) - `make validate STRICT=1` clean - `make garden` 0 errors (10 warnings — remaining oversize source skills) - `make smoke-test` clean against locally installed OpenCode/Gemini/Codex/Claude Code - Real-CLI round-trip: `opencode agent list` discovers 193 subagents, `gemini extensions validate .` succeeds, all 191 Codex agent TOMLs parse ## Tag recommendations (separate task — for repo About panel) Top 20 by reach + relevance (from `gh api search/repositories?q=topic:<tag>`): automation mcp ai-agents developer-tools claude-code anthropic agentic-ai agents prompt-engineering cursor multi-agent agent-skills orchestration opencode workflows gemini-cli codex-cli claude-code-skills cursor-rules claude-code-plugins * fix(ci): YAML multi-doc + markdownlint scope/rules Two CI failures on PR #542, both fixed: ## JSON/TOML/YAML syntax job (3 false-positive YAML errors) The job's YAML validation used `yaml.safe_load` which only reads the first document in a multi-document YAML stream. Three Kubernetes manifest templates use the standard `---` document separator (valid YAML) and were mis-flagged: plugins/kubernetes-operations/skills/k8s-manifest-generator/assets/configmap-template.yaml plugins/kubernetes-operations/skills/k8s-manifest-generator/assets/service-template.yaml plugins/kubernetes-operations/skills/k8s-security-policies/assets/network-policy-template.yaml Switched to `list(yaml.safe_load_all(...))` so multi-doc YAML is accepted. ## Markdown lint job (lots of pre-existing plugin-README violations) Two changes: 1. **Narrow the lint glob** — markdownlint now runs against top-level guides (README, AGENTS, ARCHITECTURE, CLAUDE, per-harness setup, CONTRIBUTING) and our authored `docs/` only. Per-plugin READMEs (`plugins/*/README.md`) are owned by their plugin authors and not lint-gated as part of this framework PR. Lint enforcement for those belongs at the plugin-author layer, not the framework PR layer. 2. **Tighten `.markdownlint.json`** — disable two rules that produce noise without catching real defects: - MD040 (fenced-code-language) — terminal output / shell command blocks frequently omit a language by convention - MD060 (table-column-style) — cosmetic table-pipe spacing; doesn't affect rendering Genuine formatting rules kept: MD029 (ol-prefix), MD031 (blanks- around-fences), MD032 (blanks-around-lists), MD056 (table-column- count), MD058 (blanks-around-tables). ## Real defects caught and fixed The narrower scope still caught 5 real issues: - `docs/agent-skills.md:397` — code fence inside an ordered list item needed a blank line before the fence - `docs/authoring.md:90` — bulleted list needed a blank line above - `docs/plugin-eval.md:87` — table needed a blank line above - `OPENCODE.md:39` — table-column-count error caused by literal `|` inside backticks: ``mode: primary|subagent|all`` (3 cells reads as 5) Rewrote as ``mode:` one of `primary` / `subagent` / `all`` ## Verification - `npx markdownlint-cli2 "*.md" "docs/*.md"` → 0 errors - `yaml.safe_load_all` accepts all multi-doc YAMLs (0 errors) - All other CI jobs already passing (Python ruff/ty, multi-harness generate, CLI smoke test, plugin-eval pytest, tools pytest) * fix: address PR #542 bot feedback - Codex P2 (chatgpt-codex-connector): emit_global now reads AGENTS.md from the repo root (WORKTREE), not output_root. Previously `--output-root <scratch>` produced a false "missing" warning even when AGENTS.md was committed at the real root, breaking --strict generation outside the repo. Added a constructor arg `repo_root` so tests can stage a fake AGENTS.md without touching the committed file, plus a regression test that proves the two paths are decoupled. - CodeRabbit nitpick (code-quality.yml): added workflow-level `permissions: contents: read` and `persist-credentials: false` on every checkout. Skipped the SHA-pinning recommendation — it's a heavier blanket-policy decision and the workflow has no write scope to abuse. - CodeRabbit nitpick (pyproject.toml): consolidated the duplicate `dev` groups by moving `ty` into `[project.optional-dependencies].dev` and removing the now-empty `[dependency-groups]` block. Dropped `--group dev` from `code-quality.yml`'s `uv sync` since `--all-extras` now covers it. - Verified gemini-extension.json counts (82/191/155/102) against the actual source-of-truth: 81 local plugins + 1 external = 82 in marketplace.json, 191 agent .md files, 155 SKILL.md files, 102 command .md files. Counts are correct as-is — CodeRabbit's quick-win was a regex miscount.
199 行
8.8 KiB
Python
199 行
8.8 KiB
Python
"""Real-CLI subprocess smoke tests.
|
|
|
|
Invokes the actual harness CLIs against our generated artifacts to catch issues
|
|
that pure-Python parsing can't see (CLI version drift, schema validation surprises,
|
|
plugin loader behavior).
|
|
|
|
Each test class skips gracefully when its CLI isn't installed — so local devs and
|
|
CI runners only exercise the tools they have. CI installs OpenCode + Gemini CLI
|
|
(both are quick) and the corresponding test classes become required gates.
|
|
|
|
No API keys needed: every command exercised here is local-only (`agent list`,
|
|
`extensions validate`, `doctor`, `--version`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
from tools.adapters.base import WORKTREE, list_plugins, load_plugin # noqa: E402
|
|
|
|
_TIMEOUT = 60 # seconds per subprocess call
|
|
|
|
|
|
def _has(cli: str) -> bool:
|
|
"""Return True iff a CLI is on PATH."""
|
|
return shutil.which(cli) is not None
|
|
|
|
|
|
def _run(
|
|
args: list[str], cwd: Path | None = None, env: dict | None = None
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a subprocess with a tight timeout and capture stdout/stderr."""
|
|
return subprocess.run(
|
|
args,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_TIMEOUT,
|
|
cwd=str(cwd) if cwd else None,
|
|
env=env,
|
|
)
|
|
|
|
|
|
# ── OpenCode CLI ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.skipif(not _has("opencode"), reason="opencode CLI not installed")
|
|
@pytest.mark.skipif(
|
|
not (WORKTREE / ".opencode").is_dir(),
|
|
reason="OpenCode artifacts not generated — run `make generate HARNESS=opencode`",
|
|
)
|
|
class TestOpenCodeSmoke:
|
|
@pytest.fixture(scope="class")
|
|
def opencode_workdir(self, tmp_path_factory) -> Path:
|
|
"""Stage the generated .opencode/ + opencode.json in a tmpdir so we don't
|
|
need to install into the user's ~/.opencode/."""
|
|
d = tmp_path_factory.mktemp("opencode-smoke")
|
|
shutil.copytree(WORKTREE / ".opencode", d / ".opencode")
|
|
shutil.copy(WORKTREE / "opencode.json", d / "opencode.json")
|
|
return d
|
|
|
|
def test_opencode_agent_list_succeeds(self, opencode_workdir: Path):
|
|
"""`opencode agent list` must exit 0 — failure indicates an agent frontmatter
|
|
bug, mode/model schema violation, or permission-block parse error."""
|
|
proc = _run(["opencode", "agent", "list"], cwd=opencode_workdir)
|
|
assert proc.returncode == 0, (
|
|
f"opencode agent list failed (rc={proc.returncode}):\n"
|
|
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}"
|
|
)
|
|
|
|
def test_opencode_discovers_every_source_agent(self, opencode_workdir: Path):
|
|
"""Every source agent in plugins/*/agents/ must show up in `opencode agent list`."""
|
|
proc = _run(["opencode", "agent", "list"], cwd=opencode_workdir)
|
|
assert proc.returncode == 0
|
|
listed = set()
|
|
for line in proc.stdout.splitlines():
|
|
# Lines look like `<plugin>__<agent> (subagent)` or `<name> (primary)`
|
|
line = line.strip()
|
|
if "(" in line:
|
|
listed.add(line.split("(", 1)[0].strip())
|
|
|
|
expected = set()
|
|
for plugin_name in list_plugins():
|
|
plugin = load_plugin(plugin_name)
|
|
if plugin:
|
|
expected.update(f"{plugin.name}__{a.name}" for a in plugin.agents)
|
|
|
|
missing = expected - listed
|
|
assert not missing, (
|
|
f"OpenCode failed to discover {len(missing)} agents — likely a frontmatter "
|
|
f"or permission-block bug. Missing: {sorted(missing)[:10]}{'...' if len(missing) > 10 else ''}"
|
|
)
|
|
|
|
|
|
# ── Gemini CLI ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.skipif(not _has("gemini"), reason="gemini CLI not installed")
|
|
class TestGeminiSmoke:
|
|
def test_gemini_extension_validates(self):
|
|
"""`gemini extensions validate <repo>` must return success — failure indicates
|
|
gemini-extension.json schema drift or invalid TOML in commands/."""
|
|
proc = _run(["gemini", "extensions", "validate", str(WORKTREE)])
|
|
assert proc.returncode == 0, (
|
|
f"gemini extensions validate failed (rc={proc.returncode}):\n"
|
|
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}"
|
|
)
|
|
# Success message is part of the Gemini CLI contract.
|
|
assert "successfully validated" in proc.stdout.lower() or proc.returncode == 0
|
|
|
|
|
|
# ── Codex CLI ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.skipif(not _has("codex"), reason="codex CLI not installed")
|
|
class TestCodexSmoke:
|
|
def test_codex_doctor_passes_overall(self):
|
|
"""`codex doctor` is the only no-API health check Codex CLI provides. It runs
|
|
a battery of structural checks and surfaces drift in the local install."""
|
|
proc = _run(["codex", "doctor"])
|
|
# Codex doctor returns 0 on healthy install; warnings are inline but don't fail.
|
|
assert proc.returncode == 0, (
|
|
f"codex doctor failed (rc={proc.returncode}):\n"
|
|
f"--- stdout ---\n{proc.stdout[:2000]}\n--- stderr ---\n{proc.stderr}"
|
|
)
|
|
|
|
@pytest.mark.skipif(
|
|
not (WORKTREE / ".codex").is_dir(),
|
|
reason="Codex artifacts not generated — run `make generate HARNESS=codex`",
|
|
)
|
|
def test_every_codex_agent_toml_loads_with_tomllib(self):
|
|
"""We can't directly invoke Codex on our agents (would require a session), but
|
|
every TOML must parse with the same library Codex uses."""
|
|
import tomllib
|
|
|
|
broken = []
|
|
for toml_path in (WORKTREE / ".codex" / "agents").glob("*.toml"):
|
|
try:
|
|
tomllib.loads(toml_path.read_text())
|
|
except tomllib.TOMLDecodeError as e:
|
|
broken.append(f"{toml_path.name}: {e}")
|
|
assert not broken, "Codex agent TOMLs that fail to parse:\n " + "\n ".join(broken)
|
|
|
|
|
|
# ── Claude Code CLI ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.skipif(not _has("claude"), reason="claude CLI not installed")
|
|
class TestClaudeCodeSmoke:
|
|
def test_claude_version_runs(self):
|
|
"""Sanity check that the Claude Code CLI is invokable. Doesn't load our
|
|
marketplace (that would require an actual session)."""
|
|
proc = _run(["claude", "--version"])
|
|
assert proc.returncode == 0, f"claude --version failed: {proc.stderr}"
|
|
assert "Claude Code" in proc.stdout or "claude" in proc.stdout.lower()
|
|
|
|
def test_marketplace_json_loads_via_python(self):
|
|
"""The marketplace.json must parse as JSON (covers Claude Code's loader path)."""
|
|
mp = json.loads((WORKTREE / ".claude-plugin" / "marketplace.json").read_text())
|
|
assert mp.get("plugins"), "marketplace.json has no plugins[]"
|
|
# Owner/metadata are required for Claude Code's marketplace loader.
|
|
assert mp.get("owner"), "marketplace.json missing top-level 'owner'"
|
|
assert mp.get("metadata", {}).get("version"), "marketplace.json missing metadata.version"
|
|
|
|
|
|
# ── Cross-CLI sanity: marketplace + adapter agreement ────────────────────────
|
|
|
|
|
|
class TestMarketplaceAgreement:
|
|
"""No CLI needed — checks the static contract between marketplace.json and
|
|
what the adapters produce. Catches version-bump drift and missing entries."""
|
|
|
|
def test_every_marketplace_local_entry_has_synced_version(self):
|
|
mp = json.loads((WORKTREE / ".claude-plugin" / "marketplace.json").read_text())
|
|
drift = []
|
|
for entry in mp.get("plugins", []):
|
|
source = entry.get("source")
|
|
if not (isinstance(source, str) and source.startswith("./plugins/")):
|
|
continue
|
|
pj_path = WORKTREE / source.removeprefix("./") / ".claude-plugin" / "plugin.json"
|
|
if not pj_path.is_file():
|
|
continue
|
|
pj = json.loads(pj_path.read_text())
|
|
if entry.get("version") != pj.get("version"):
|
|
drift.append(
|
|
f"{entry['name']}: marketplace={entry.get('version')} "
|
|
f"vs plugin.json={pj.get('version')}"
|
|
)
|
|
assert not drift, "Version drift:\n " + "\n ".join(drift)
|