refactor: agents and skills move to a client-neutral source that renders per client
`.claude/agents/` was the source of truth, which made every role Claude-Code shaped. Adding a second client meant rewriting each role in that client's syntax and maintaining both copies — the drift this scaffold exists to prevent, one layer up. Roles and skills now live under `.agents/` and render into each registered client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated; `scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI. The role metadata is portable rather than vendor-named: `reasoning_tier` (deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an optional `preload_skills`. A client manifest maps those to native syntax and must declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported` and its adapters say so in the file, rather than the tier silently evaporating and leaving the repository to believe it was enforced. The port is behaviour-preserving where it should be and a fix where it should not. Every instruction body is byte-identical — the whole diff to `.claude/agents/` is 18 added lines and zero deletions. What changed is frontmatter that was missing: - four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`) declared no `tools:` and therefore inherited the ENTIRE tool pool, so three review-only agents could edit and write the code they were reviewing. All eight now declare capabilities explicitly. - the six read-only roles gain a non-editing permission mode, so the constraint is enforced by the client rather than by the prompt asking nicely. - `mutation` is now explicit, which records the two roles that genuinely need to write: `pr-creator` (external-write — it pushes a branch and opens a PR) and `dependency-audit` (workspace-write — package managers rewrite lockfiles). `pr-creator` keeps `shell` because opening a PR needs it, but it is now the only agent here with a write mutation and a declared reason for it, instead of one of four with unlimited access by omission.
This commit is contained in:
Executable
+539
@@ -0,0 +1,539 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate native agent-client adapters from client-neutral repository sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_DEFAULT_ROOT = Path(__file__).resolve().parents[1]
|
||||
_ROLE_MARKER = "Generated from .agents/roles/"
|
||||
_SKILL_MARKER = ".generated-from-agents"
|
||||
_MUTATION_POLICIES = frozenset({"read-only", "workspace-write", "external-write"})
|
||||
_FORMATS = frozenset({"claude-markdown", "codex-toml"})
|
||||
|
||||
#: Tier-handling policies a client manifest may declare. `unsupported` is a
|
||||
#: deliberate, visible position rather than an omission — the alternative
|
||||
#: considered was emitting a model key the client ignores, which would leave the
|
||||
#: repository believing tiers were enforced where nothing read them.
|
||||
TIER_MAPPED = "mapped"
|
||||
TIER_UNSUPPORTED = "unsupported"
|
||||
_TIER_POLICIES = frozenset({TIER_MAPPED, TIER_UNSUPPORTED})
|
||||
|
||||
|
||||
#: Printed where a role declares `preload_skills` and the client cannot honour
|
||||
#: them. Names the skills so the reader knows which standard the agent will NOT
|
||||
#: arrive holding, rather than the declaration disappearing between layers.
|
||||
def _preload_notice(skills: tuple[str, ...], comment: str) -> str:
|
||||
named = ", ".join(skills)
|
||||
return (
|
||||
f"{comment} This role declares preload_skills ({named}) and this client\n"
|
||||
f"{comment} cannot preload: the standard is NOT in context at launch. It\n"
|
||||
f"{comment} remains discoverable under .agents/skills/ and must be read.\n"
|
||||
)
|
||||
|
||||
|
||||
#: Printed into an adapter generated for a client that cannot express tiers, so
|
||||
#: the gap is legible in the artifact a reader actually opens.
|
||||
_TIER_NOTICE = (
|
||||
"# The role's reasoning tier is NOT enforced here: this client declares\n"
|
||||
"# tier_policy = unsupported, so the session default applies. The tier is\n"
|
||||
"# still authoritative in .agents/roles/ and enforced for clients that map it.\n"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleDefinition:
|
||||
source: Path
|
||||
name: str
|
||||
description: str
|
||||
reasoning_tier: str
|
||||
capabilities: tuple[str, ...]
|
||||
mutation: str
|
||||
instructions: str
|
||||
#: Skills whose content this role is launched with already in context.
|
||||
#: Portable: a client that cannot preload records that it dropped them.
|
||||
preload_skills: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientDefinition:
|
||||
name: str
|
||||
agent_format: str
|
||||
agents_path: Path
|
||||
skills_path: Path | None
|
||||
reasoning_tiers: dict[str, str]
|
||||
capabilities: dict[str, str]
|
||||
#: How this client handles a role's portable reasoning tier. `mapped` means
|
||||
#: every tier a role uses must resolve through `reasoning_tiers` or the sync
|
||||
#: refuses; `unsupported` means the client cannot express one, no tier is
|
||||
#: rendered, and the generated adapter says so where a reader will see it.
|
||||
#: There is no default: a client that does not declare is refused, because
|
||||
#: silently dropping the tier is how `codex.yaml` came to run every
|
||||
#: merge-blocking reviewer on the session default (HF-2026-001173).
|
||||
tier_policy: str
|
||||
#: Whether this client can launch an agent with named skills already
|
||||
#: loaded. False means the role's `preload_skills` cannot be honoured, and
|
||||
#: the generated adapter says so rather than dropping it silently.
|
||||
preload_support: bool = False
|
||||
|
||||
|
||||
def _read_json_compatible_yaml(path: Path) -> dict[str, Any]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
payload = json.loads(
|
||||
"\n".join(
|
||||
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||
)
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"{path}: client definition must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _required_string(payload: dict[str, Any], field: str, source: Path) -> str:
|
||||
value = payload.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{source}: {field} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _string_map(payload: dict[str, Any], field: str, source: Path) -> dict[str, str]:
|
||||
value = payload.get(field, {})
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(key, str) and key and isinstance(mapped, str) and mapped
|
||||
for key, mapped in value.items()
|
||||
):
|
||||
raise ValueError(f"{source}: {field} must map strings to strings")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _destination(root: Path, value: Any, field: str, source: Path) -> Path | None:
|
||||
if value is None and field == "skills_path":
|
||||
return None
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{source}: {field} must be a non-empty relative path")
|
||||
relative = Path(value)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise ValueError(f"{source}: {field} must stay inside the repository")
|
||||
return root / relative
|
||||
|
||||
|
||||
def load_client(source: Path, root: Path) -> ClientDefinition:
|
||||
"""Load and validate one declarative client adapter definition."""
|
||||
payload = _read_json_compatible_yaml(source)
|
||||
agent_format = _required_string(payload, "agent_format", source)
|
||||
if agent_format not in _FORMATS:
|
||||
raise ValueError(f"{source}: unsupported agent_format {agent_format!r}")
|
||||
agents_path = _destination(root, payload.get("agents_path"), "agents_path", source)
|
||||
assert agents_path is not None
|
||||
tiers = _string_map(payload, "reasoning_tiers", source)
|
||||
tier_policy = _required_string(payload, "tier_policy", source)
|
||||
if tier_policy not in _TIER_POLICIES:
|
||||
raise ValueError(
|
||||
f"{source}: unsupported tier_policy {tier_policy!r} — expected one of "
|
||||
f"{', '.join(sorted(_TIER_POLICIES))}"
|
||||
)
|
||||
if tier_policy == TIER_UNSUPPORTED and tiers:
|
||||
raise ValueError(
|
||||
f"{source}: declares tier_policy {TIER_UNSUPPORTED!r} but also maps "
|
||||
f"{sorted(tiers)} — one of the two is wrong, and a contradictory "
|
||||
f"manifest misleads the next reader about what is enforced"
|
||||
)
|
||||
return ClientDefinition(
|
||||
name=_required_string(payload, "name", source),
|
||||
agent_format=agent_format,
|
||||
agents_path=agents_path,
|
||||
skills_path=_destination(
|
||||
root, payload.get("skills_path"), "skills_path", source
|
||||
),
|
||||
reasoning_tiers=tiers,
|
||||
capabilities=_string_map(payload, "capabilities", source),
|
||||
tier_policy=tier_policy,
|
||||
preload_support=bool(payload.get("preload_support", False)),
|
||||
)
|
||||
|
||||
|
||||
def validate_preloads(
|
||||
preloads: tuple[str, ...], root: Path, source: Path
|
||||
) -> tuple[str, ...]:
|
||||
"""Refuse a preload naming a skill that does not exist.
|
||||
|
||||
A preload pointing at a renamed or deleted skill loads nothing, and the role
|
||||
goes back to grading against whatever it already knew with no indication
|
||||
anything is missing. Validated when the role is read, so the failure lands on
|
||||
the person editing it rather than in a review session weeks later.
|
||||
"""
|
||||
for skill in preloads:
|
||||
if not (root / ".agents" / "skills" / skill / "SKILL.md").is_file():
|
||||
raise ValueError(
|
||||
f"{source}: preload_skills names {skill!r}, which has no "
|
||||
f".agents/skills/{skill}/SKILL.md"
|
||||
)
|
||||
return preloads
|
||||
|
||||
|
||||
def parse_role(source: Path) -> RoleDefinition:
|
||||
"""Parse one portable role definition and validate its common contract."""
|
||||
text = source.read_text(encoding="utf-8")
|
||||
if not text.startswith("---\n"):
|
||||
raise ValueError(f"{source}: missing YAML frontmatter")
|
||||
try:
|
||||
frontmatter, body = text[4:].split("\n---\n", maxsplit=1)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{source}: unclosed YAML frontmatter") from error
|
||||
|
||||
metadata: dict[str, str] = {}
|
||||
for line in frontmatter.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
key, separator, value = line.partition(":")
|
||||
if not separator:
|
||||
raise ValueError(f"{source}: unsupported frontmatter line {line!r}")
|
||||
metadata[key.strip()] = value.strip()
|
||||
|
||||
required = {"name", "description", "reasoning_tier", "capabilities", "mutation"}
|
||||
missing = required - metadata.keys()
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"{source}: missing required frontmatter: {', '.join(sorted(missing))}"
|
||||
)
|
||||
mutation = metadata["mutation"]
|
||||
if mutation not in _MUTATION_POLICIES:
|
||||
raise ValueError(f"{source}: unsupported mutation policy {mutation!r}")
|
||||
capabilities = tuple(
|
||||
item.strip() for item in metadata["capabilities"].split(",") if item.strip()
|
||||
)
|
||||
if not capabilities:
|
||||
raise ValueError(f"{source}: capabilities must not be empty")
|
||||
preloads = tuple(
|
||||
item.strip()
|
||||
for item in metadata.get("preload_skills", "").split(",")
|
||||
if item.strip()
|
||||
)
|
||||
validate_preloads(preloads, _DEFAULT_ROOT, source)
|
||||
return RoleDefinition(
|
||||
source=source,
|
||||
name=metadata["name"],
|
||||
description=metadata["description"],
|
||||
reasoning_tier=metadata["reasoning_tier"],
|
||||
capabilities=capabilities,
|
||||
mutation=mutation,
|
||||
instructions=body.strip() + "\n",
|
||||
preload_skills=preloads,
|
||||
)
|
||||
|
||||
|
||||
def _mapped_capabilities(role: RoleDefinition, client: ClientDefinition) -> list[str]:
|
||||
missing = set(role.capabilities) - client.capabilities.keys()
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"{client.name}: no capability mapping for {', '.join(sorted(missing))}"
|
||||
)
|
||||
return [client.capabilities[name] for name in role.capabilities]
|
||||
|
||||
|
||||
def _render_claude(role: RoleDefinition, client: ClientDefinition) -> str:
|
||||
tier = _resolve_tier(role, client)
|
||||
tools = _mapped_capabilities(role, client)
|
||||
lines = [
|
||||
"---",
|
||||
f"name: {role.name}",
|
||||
f"description: {role.description}",
|
||||
]
|
||||
if tier is None:
|
||||
# A markdown client declaring tier_policy: unsupported. Not reachable
|
||||
# from claude-code.yaml, which maps every tier; written so a future
|
||||
# manifest cannot render the literal string "None" as a model.
|
||||
lines.append(f"<!-- {_TIER_NOTICE.strip()} -->")
|
||||
elif tier != "inherit":
|
||||
lines.append(f"model: {tier}")
|
||||
lines.append(f"tools: {', '.join(tools)}")
|
||||
if role.preload_skills:
|
||||
if client.preload_support:
|
||||
lines.append(f"skills: {', '.join(role.preload_skills)}")
|
||||
else:
|
||||
lines.append(f"<!-- {_preload_notice(role.preload_skills, '').strip()} -->")
|
||||
if role.mutation == "read-only":
|
||||
lines.append("permissionMode: plan")
|
||||
lines.extend(
|
||||
[
|
||||
"---",
|
||||
f"<!-- {_ROLE_MARKER}{role.source.name}; edit the neutral source. -->",
|
||||
"",
|
||||
role.instructions.rstrip(),
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _toml_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _toml_instructions(value: str) -> str:
|
||||
if "'''" in value:
|
||||
return _toml_string(value)
|
||||
return "'''\n" + value + "'''"
|
||||
|
||||
|
||||
def _resolve_tier(role: RoleDefinition, client: ClientDefinition) -> str | None:
|
||||
"""The client's model for this role's tier, or None where it has no concept.
|
||||
|
||||
Shared by both renderers so "what does this client do with a tier?" has one
|
||||
answer. Previously `_render_codex` opened with `del client`, which made the
|
||||
question unanswerable for Codex and silently dropped every tier.
|
||||
"""
|
||||
if client.tier_policy == TIER_UNSUPPORTED:
|
||||
return None
|
||||
tier = client.reasoning_tiers.get(role.reasoning_tier)
|
||||
if tier is None:
|
||||
raise ValueError(
|
||||
f"{client.name}: no reasoning-tier mapping for {role.reasoning_tier!r}"
|
||||
)
|
||||
return tier
|
||||
|
||||
|
||||
def _render_codex(role: RoleDefinition, client: ClientDefinition) -> str:
|
||||
sandbox = 'sandbox_mode = "read-only"\n' if role.mutation == "read-only" else ""
|
||||
tier = _resolve_tier(role, client)
|
||||
if tier is None:
|
||||
notice, model = _TIER_NOTICE, ""
|
||||
else:
|
||||
notice = ""
|
||||
model = "" if tier == "inherit" else f"model = {_toml_string(tier)}\n"
|
||||
if role.preload_skills:
|
||||
if client.preload_support:
|
||||
listed = ", ".join(_toml_string(s) for s in role.preload_skills)
|
||||
model += f"skills = [{listed}]\n"
|
||||
else:
|
||||
notice += _preload_notice(role.preload_skills, "#")
|
||||
return (
|
||||
f"# {_ROLE_MARKER}{role.source.name} by scripts/sync-agent-integrations.py.\n"
|
||||
"# Edit the client-neutral role, then rerun the sync script.\n"
|
||||
f"{notice}"
|
||||
f"name = {_toml_string(role.name)}\n"
|
||||
f"description = {_toml_string(role.description)}\n"
|
||||
f"{model}"
|
||||
f"{sandbox}"
|
||||
f"developer_instructions = {_toml_instructions(role.instructions)}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_agent(role: RoleDefinition, client: ClientDefinition) -> str:
|
||||
"""Render a portable role into one client's native agent format."""
|
||||
if client.agent_format == "claude-markdown":
|
||||
return _render_claude(role, client)
|
||||
if client.agent_format == "codex-toml":
|
||||
return _render_codex(role, client)
|
||||
raise ValueError(f"{client.name}: unsupported agent format {client.agent_format!r}")
|
||||
|
||||
|
||||
def _extension(client: ClientDefinition) -> str:
|
||||
return ".md" if client.agent_format == "claude-markdown" else ".toml"
|
||||
|
||||
|
||||
def _expected_agents(
|
||||
roles: list[RoleDefinition], client: ClientDefinition
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
f"{role.name}{_extension(client)}": render_agent(role, client) for role in roles
|
||||
}
|
||||
|
||||
|
||||
def _is_generated_agent(path: Path) -> bool:
|
||||
try:
|
||||
return _ROLE_MARKER in path.read_text(encoding="utf-8")[:2048]
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _skill_files(root: Path) -> dict[Path, bytes]:
|
||||
source = root / ".agents" / "skills"
|
||||
expected: dict[Path, bytes] = {}
|
||||
if not source.exists():
|
||||
return expected
|
||||
for path in sorted(source.rglob("*")):
|
||||
if (
|
||||
path.is_file()
|
||||
and path.name != _SKILL_MARKER
|
||||
and "__pycache__" not in path.parts
|
||||
):
|
||||
expected[path.relative_to(source)] = path.read_bytes()
|
||||
return expected
|
||||
|
||||
|
||||
def _check_client(
|
||||
client: ClientDefinition,
|
||||
expected_agents: dict[str, str],
|
||||
expected_skills: dict[Path, bytes],
|
||||
) -> list[str]:
|
||||
findings: list[str] = []
|
||||
for name, rendered in expected_agents.items():
|
||||
target = client.agents_path / name
|
||||
if not target.exists():
|
||||
findings.append(f"missing: {target}")
|
||||
elif target.read_text(encoding="utf-8") != rendered:
|
||||
findings.append(f"stale: {target}")
|
||||
if client.agents_path.exists():
|
||||
expected_names = set(expected_agents)
|
||||
for target in client.agents_path.iterdir():
|
||||
if (
|
||||
target.is_file()
|
||||
and _is_generated_agent(target)
|
||||
and target.name not in expected_names
|
||||
):
|
||||
findings.append(f"orphaned generated agent: {target}")
|
||||
|
||||
if client.skills_path is not None:
|
||||
for relative, content in expected_skills.items():
|
||||
target = client.skills_path / relative
|
||||
if not target.exists():
|
||||
findings.append(f"missing: {target}")
|
||||
elif target.read_bytes() != content:
|
||||
findings.append(f"stale: {target}")
|
||||
if client.skills_path.exists():
|
||||
expected_paths = set(expected_skills)
|
||||
expected_roots = {path.parts[0] for path in expected_paths if path.parts}
|
||||
for child in client.skills_path.iterdir():
|
||||
if not child.is_dir() or not child.joinpath(_SKILL_MARKER).is_file():
|
||||
continue
|
||||
if child.name not in expected_roots:
|
||||
findings.append(f"orphaned generated skill: {child}")
|
||||
continue
|
||||
for target in child.rglob("*"):
|
||||
if (
|
||||
target.is_file()
|
||||
and target.name != _SKILL_MARKER
|
||||
and target.relative_to(client.skills_path) not in expected_paths
|
||||
):
|
||||
findings.append(f"orphaned generated skill file: {target}")
|
||||
return findings
|
||||
|
||||
|
||||
def _sync_client(
|
||||
client: ClientDefinition,
|
||||
expected_agents: dict[str, str],
|
||||
expected_skills: dict[Path, bytes],
|
||||
) -> list[str]:
|
||||
changes: list[str] = []
|
||||
client.agents_path.mkdir(parents=True, exist_ok=True)
|
||||
for name, rendered in expected_agents.items():
|
||||
target = client.agents_path / name
|
||||
if not target.exists() or target.read_text(encoding="utf-8") != rendered:
|
||||
target.write_text(rendered, encoding="utf-8")
|
||||
changes.append(f"wrote: {target}")
|
||||
expected_names = set(expected_agents)
|
||||
for target in client.agents_path.iterdir():
|
||||
if (
|
||||
target.is_file()
|
||||
and _is_generated_agent(target)
|
||||
and target.name not in expected_names
|
||||
):
|
||||
target.unlink()
|
||||
changes.append(f"removed: {target}")
|
||||
|
||||
if client.skills_path is not None:
|
||||
for relative, content in expected_skills.items():
|
||||
target = client.skills_path / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not target.exists() or target.read_bytes() != content:
|
||||
target.write_bytes(content)
|
||||
changes.append(f"wrote: {target}")
|
||||
for skill_dir in {path.parts[0] for path in expected_skills if path.parts}:
|
||||
marker = client.skills_path / skill_dir / _SKILL_MARKER
|
||||
marker.write_text(
|
||||
"Generated from .agents/skills by scripts/sync-agent-integrations.py.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if client.skills_path.exists():
|
||||
expected_paths = set(expected_skills)
|
||||
expected_roots = {path.parts[0] for path in expected_skills if path.parts}
|
||||
for child in client.skills_path.iterdir():
|
||||
if not child.is_dir() or not child.joinpath(_SKILL_MARKER).is_file():
|
||||
continue
|
||||
if child.name not in expected_roots:
|
||||
shutil.rmtree(child)
|
||||
changes.append(f"removed: {child}")
|
||||
continue
|
||||
for target in sorted(child.rglob("*"), reverse=True):
|
||||
if (
|
||||
target.is_file()
|
||||
and target.name != _SKILL_MARKER
|
||||
and target.relative_to(client.skills_path) not in expected_paths
|
||||
):
|
||||
target.unlink()
|
||||
changes.append(f"removed: {target}")
|
||||
elif target.is_dir() and not any(target.iterdir()):
|
||||
target.rmdir()
|
||||
return changes
|
||||
|
||||
|
||||
def _load_repository(root: Path) -> tuple[list[RoleDefinition], list[ClientDefinition]]:
|
||||
roles = [
|
||||
parse_role(path) for path in sorted((root / ".agents" / "roles").glob("*.md"))
|
||||
]
|
||||
clients = [
|
||||
load_client(path, root)
|
||||
for path in sorted((root / ".agents" / "clients").glob("*.yaml"))
|
||||
]
|
||||
if not roles:
|
||||
raise ValueError(f"{root / '.agents/roles'}: no role definitions found")
|
||||
if not clients:
|
||||
raise ValueError(f"{root / '.agents/clients'}: no client definitions found")
|
||||
return roles, clients
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=_DEFAULT_ROOT)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="report adapter drift and exit non-zero instead of updating files",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
root = args.root.resolve()
|
||||
try:
|
||||
roles, clients = _load_repository(root)
|
||||
skills = _skill_files(root)
|
||||
if args.check:
|
||||
findings: list[str] = []
|
||||
for client in clients:
|
||||
findings.extend(
|
||||
_check_client(client, _expected_agents(roles, client), skills)
|
||||
)
|
||||
if findings:
|
||||
print("\n".join(findings))
|
||||
return 1
|
||||
print(
|
||||
f"Agent client adapters are current ({len(roles)} roles, "
|
||||
f"{len(clients)} clients)"
|
||||
)
|
||||
return 0
|
||||
|
||||
changes: list[str] = []
|
||||
for client in clients:
|
||||
changes.extend(
|
||||
_sync_client(client, _expected_agents(roles, client), skills)
|
||||
)
|
||||
print(
|
||||
"\n".join(changes) if changes else "Agent client adapters already current"
|
||||
)
|
||||
return 0
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user