Eleven agents lived only in `~/.claude/agents/`, which is not a git repository — they would have gone with the laptop. Eight of those were product-specific reviewers already defined in the projects that need them, and they are removed from the user level rather than duplicated here. Two were genuinely generic and already product-neutral (zero references to any product, customer or employer), so they belong in the shared scaffold: `mr-reviewer` reviews open MRs/PRs on a non-primary remote in a multi-remote repository, and `solution-reviewer` does a whole-codebase health review. `irs-validator` is deliberately NOT brought in. It is personal tooling rather than a development capability, and per the user CLAUDE.md personal tooling must not be referenced inside a project repository because those are mirrored to other remotes. The client manifest gains `fetch` and `websearch` capability mappings, which `solution-reviewer` needs. Worth noting how that surfaced: the sync REFUSED to render rather than dropping the two tools silently, which is the fail-safe behaviour the capability mapping exists to provide — an unmapped capability that rendered as absent would have quietly removed the agent's web access. `~/.claude/agents/README.md` now records that ten of the eleven agents there are installed output from this repository, how to regenerate and reinstall them, and which one is deliberately personal.
573 lines
22 KiB
Python
Executable File
573 lines
22 KiB
Python
Executable File
#!/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}")
|
|
# A name is an identifier and appears in paths, matrix routing and Rust
|
|
# constants, so it stays a plain slug. A DESCRIPTION is prose and will
|
|
# legitimately contain a colon ("problems: missing indexes on FK columns"),
|
|
# so it is not the author's job to avoid YAML metacharacters — the renderer
|
|
# quotes it. See `_yaml_scalar`.
|
|
if ": " in metadata["name"]:
|
|
raise ValueError(
|
|
f"{source}: name must be a plain slug, got {metadata['name']!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 _yaml_scalar(value: str) -> str:
|
|
"""Render a string as YAML that parses back to exactly this string.
|
|
|
|
A plain (unquoted) scalar cannot contain `: `, cannot contain ` #`, and cannot
|
|
begin with an indicator character. HF-2026-001173 shipped a description with
|
|
`here: ` in it, and the Rust model-policy gate — which parses frontmatter as
|
|
real YAML — refused the file the sync had just called current.
|
|
|
|
Quoting rather than refusing, because a description is prose: MA's
|
|
`data-architect` reads "…for schema-design and database-health problems:
|
|
missing indexes on FK/filter/join columns…". Making an author reword that to
|
|
satisfy a renderer puts the tool's limitation into the standard's wording.
|
|
"""
|
|
unsafe = (
|
|
": " in value
|
|
or " #" in value
|
|
or value.rstrip().endswith(":")
|
|
or value[:1] in "#&*!|>%@`'\"[]{},"
|
|
)
|
|
if not unsafe:
|
|
return value
|
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
|
|
|
|
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: {_yaml_scalar(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())
|