agent2you 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
a2y/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agent2you -- deploy a team of chat-resident AI agents from declarative manifests."""
2
+
3
+ __version__ = "1.0.0"
a2y/agents_cmd.py ADDED
@@ -0,0 +1,184 @@
1
+ """`a2y agent ...` -- manage agents in a fleet workspace.
2
+
3
+ `a2y agent add` is deliberately non-interactive and single-shot: the intended
4
+ interactive layer is a fleet agent (the assistant) interviewing the operator in
5
+ chat and then calling this command with the answers. The tool stays
6
+ deterministic; the conversation stays where conversations belong. See
7
+ docs/hiring.md for the interview the assistant runs.
8
+
9
+ Structured input: `--json` accepts a full agent.yaml body (file path or `-` for
10
+ stdin), for callers that would rather build the manifest than spell flags.
11
+ Flags win over `--json` keys.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ from .manifest import NAME_RE, ManifestError, load_fleet
24
+
25
+ SOUL_SKELETON = """\
26
+ # {name}
27
+
28
+ You are {name}. {description}
29
+
30
+ ## Scope
31
+
32
+ - What you own and answer for. Be precise: your colleagues route by this.
33
+ - What you do NOT touch, even when convenient.
34
+
35
+ ## How you work
36
+
37
+ - Never report success for a tool call that failed; quote the error instead.
38
+ - "Posted" is not "delivered": a mention reaches only members of that channel.
39
+ """
40
+
41
+
42
+ def cmd_agent_add(ns: argparse.Namespace) -> int:
43
+ root = Path.cwd()
44
+ if not (root / "fleet.yaml").is_file():
45
+ print("a2y: no fleet.yaml here -- run from a fleet workspace", file=sys.stderr)
46
+ return 2
47
+
48
+ name = ns.name
49
+ if not NAME_RE.match(name):
50
+ print(f"a2y: agent name {name!r} must be lowercase [a-z0-9-]", file=sys.stderr)
51
+ return 2
52
+ agent_dir = root / "agents" / name
53
+ if agent_dir.exists():
54
+ print(f"a2y: agents/{name}/ already exists", file=sys.stderr)
55
+ return 2
56
+
57
+ manifest: dict = {}
58
+ if ns.json:
59
+ raw = sys.stdin.read() if ns.json == "-" else Path(ns.json).read_text()
60
+ try:
61
+ manifest = json.loads(raw)
62
+ except json.JSONDecodeError as exc:
63
+ print(f"a2y: --json is not valid JSON ({exc})", file=sys.stderr)
64
+ return 2
65
+ if not isinstance(manifest, dict):
66
+ print("a2y: --json must be an object (the agent.yaml body)", file=sys.stderr)
67
+ return 2
68
+
69
+ manifest["name"] = name
70
+ if ns.description:
71
+ manifest["description"] = ns.description
72
+ if not str(manifest.get("description") or "").strip():
73
+ print("a2y: --description is required (it is the agent's card and roster entry)",
74
+ file=sys.stderr)
75
+ return 2
76
+
77
+ if ns.chain:
78
+ brains = manifest.setdefault("brains", {})
79
+ brains["chain"] = [s.strip() for s in ns.chain.split(",") if s.strip()]
80
+ if ns.ssh or ns.github_token:
81
+ access = manifest.setdefault("access", {})
82
+ if ns.ssh:
83
+ access["ssh"] = True
84
+ if ns.github_token:
85
+ access["github_token"] = True
86
+ if ns.projects:
87
+ memory = manifest.setdefault("memory", {})
88
+ memory["projects"] = [s.strip() for s in ns.projects.split(",") if s.strip()]
89
+ if ns.reply_mode or ns.no_require_mention:
90
+ platform = manifest.setdefault("platform", {})
91
+ if ns.reply_mode:
92
+ platform["reply_mode"] = ns.reply_mode
93
+ if ns.no_require_mention:
94
+ platform["require_mention"] = False
95
+ if ns.ports_base:
96
+ manifest["ports"] = {"base": int(ns.ports_base)}
97
+
98
+ soul = SOUL_SKELETON.format(name=name, description=manifest["description"])
99
+ if ns.soul_file:
100
+ soul = sys.stdin.read() if ns.soul_file == "-" else Path(ns.soul_file).read_text()
101
+
102
+ # Write, then validate by loading the whole fleet; roll back on failure so a
103
+ # bad call leaves no half-created agent behind.
104
+ agent_dir.mkdir(parents=True)
105
+ (agent_dir / "agent.yaml").write_text(
106
+ yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True, width=100))
107
+ (agent_dir / "SOUL.md").write_text(soul)
108
+ try:
109
+ fleet = load_fleet(root)
110
+ except ManifestError as exc:
111
+ (agent_dir / "agent.yaml").unlink()
112
+ (agent_dir / "SOUL.md").unlink()
113
+ agent_dir.rmdir()
114
+ print(f"a2y: rolled back agents/{name}/ -- {exc}", file=sys.stderr)
115
+ return 2
116
+
117
+ print(f" created agents/{name}/agent.yaml")
118
+ print(f" created agents/{name}/SOUL.md" + ("" if ns.soul_file else " (skeleton -- write the real soul)"))
119
+
120
+ if not ns.no_render:
121
+ from .render import render_fleet
122
+ for rel in render_fleet(fleet):
123
+ print(f" wrote deploy/{rel}")
124
+
125
+ agent = next(a for a in fleet.agents if a.name == name)
126
+ p = agent.env_prefix
127
+ print(f"\n=== next steps for {name} ===")
128
+ step = 1
129
+ print(f" {step}. add to deploy/.env: {p}_LITELLM_MASTER_KEY=<random>"); step += 1
130
+ if fleet.platform_kind == "mattermost":
131
+ print(f" {step}. `a2y provision {name}` -- create the Mattermost account, then set")
132
+ print(f" {p}_MATTERMOST_TOKEN, {p}_MATTERMOST_HOME_CHANNEL (and empty {p}_MATTERMOST_CHANNELS)")
133
+ step += 1
134
+ print(f" {step}. append the new USER ID to A2Y_MATTERMOST_ALLOWED_USERS and RECREATE the")
135
+ print(f" other agents (`a2y up` recreates on env change) -- without this, messages")
136
+ print(f" from {name} are dropped silently"); step += 1
137
+ if agent.access.get("github_token"):
138
+ print(f" {step}. set {p}_GH_TOKEN (fine-grained PAT scoped to its repositories)"); step += 1
139
+ print(f" {step}. `a2y up {name}`"); step += 1
140
+ print(f" {step}. `a2y auth {name}` -- sign the brains in (device-code flows)"); step += 1
141
+ if agent.access.get("ssh"):
142
+ print(f" {step}. register the git deploy key the entrypoint prints on first start"); step += 1
143
+ print(f" {step}. verify with a real mention in the channel; `a2y doctor` last")
144
+ return 0
145
+
146
+
147
+ def cmd_agent_list(_: argparse.Namespace) -> int:
148
+ fleet = load_fleet(Path.cwd())
149
+ for a in fleet.agents:
150
+ marks = []
151
+ if a.access.get("ssh"):
152
+ marks.append("ssh")
153
+ if a.access.get("github_token"):
154
+ marks.append("gh-token")
155
+ if a.project_banks():
156
+ marks.append("projects:" + ",".join(a.project_banks()))
157
+ suffix = f" [{'; '.join(marks)}]" if marks else ""
158
+ print(f" {a.name} ({' -> '.join(a.chain)}){suffix}")
159
+ print(f" {a.description}")
160
+ return 0
161
+
162
+
163
+ def register(sub: argparse._SubParsersAction) -> None:
164
+ p = sub.add_parser("agent", help="manage agents (add, list)")
165
+ ssub = p.add_subparsers(dest="agent_cmd", required=True)
166
+
167
+ pa = ssub.add_parser(
168
+ "add", help="add an agent non-interactively (built for being called BY an agent)")
169
+ pa.add_argument("name")
170
+ pa.add_argument("--description", help="what the agent owns and answers for (required)")
171
+ pa.add_argument("--chain", help="brain chain, e.g. claude,codex (default: fleet defaults)")
172
+ pa.add_argument("--ssh", action="store_true", help="mount an ssh key volume")
173
+ pa.add_argument("--github-token", action="store_true", help="expects AGENT_<N>_GH_TOKEN")
174
+ pa.add_argument("--projects", help="shared memory banks, comma-separated")
175
+ pa.add_argument("--reply-mode", choices=["thread", "channel"])
176
+ pa.add_argument("--no-require-mention", action="store_true")
177
+ pa.add_argument("--ports-base", help="port block base (shared-namespace fleets only)")
178
+ pa.add_argument("--soul-file", help="SOUL.md content from a file, or - for stdin")
179
+ pa.add_argument("--json", help="full agent.yaml body as JSON (file or -); flags win")
180
+ pa.add_argument("--no-render", action="store_true")
181
+ pa.set_defaults(fn=cmd_agent_add)
182
+
183
+ pl = ssub.add_parser("list", help="list agents with chain and access")
184
+ pl.set_defaults(fn=cmd_agent_list)
a2y/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ """The a2y command line.
2
+
3
+ a2y init <dir> [--name N] create a fleet workspace
4
+ a2y agent add <name> ... add an agent (non-interactive; see docs/hiring.md)
5
+ a2y agent list list agents
6
+ a2y render manifests -> deploy/
7
+ a2y build build the agent image from ./image
8
+ a2y up [agent ...] ensure volumes, then docker compose up -d
9
+ a2y down [agent ...] docker compose stop
10
+ a2y doctor check manifests, env parity, compose, logins
11
+ a2y auth [agent] print the interactive sign-in instructions
12
+ a2y provision [agent] print the messenger provisioning sequence
13
+
14
+ Run everything except `init` from a fleet workspace (the directory with
15
+ fleet.yaml).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from . import __version__
26
+ from .manifest import Fleet, ManifestError, load_fleet
27
+
28
+
29
+ def _fleet() -> Fleet:
30
+ return load_fleet(Path.cwd())
31
+
32
+
33
+ def _compose(fleet: Fleet, *args: str) -> int:
34
+ deploy = fleet.root / "deploy"
35
+ env_file = deploy / ".env"
36
+ cmd = ["docker", "compose", "-f", str(deploy / "docker-compose.yaml")]
37
+ if env_file.is_file():
38
+ cmd += ["--env-file", str(env_file)]
39
+ cmd += list(args)
40
+ return subprocess.call(cmd)
41
+
42
+
43
+ def cmd_init(ns: argparse.Namespace) -> int:
44
+ from .scaffold import init_workspace
45
+
46
+ dest = Path(ns.dir).resolve()
47
+ dest.mkdir(parents=True, exist_ok=True)
48
+ name = ns.name or dest.name
49
+ created = init_workspace(dest, name=name, first_agent=ns.agent)
50
+ if not created:
51
+ print(f"{dest}: nothing to do (already initialised)")
52
+ return 0
53
+ for rel in created:
54
+ print(f" created {rel}")
55
+ print(
56
+ f"\nNext:\n"
57
+ f" 1. edit {dest / 'fleet.yaml'} and agents/{ns.agent}/\n"
58
+ f" 2. a2y render && a2y build\n"
59
+ f" 3. cp deploy/example.env deploy/.env # then fill it\n"
60
+ f" 4. a2y up && a2y auth {ns.agent}\n"
61
+ f" docs: see the agent2you pack's docs/ directory"
62
+ )
63
+ return 0
64
+
65
+
66
+ def cmd_render(_: argparse.Namespace) -> int:
67
+ from .render import render_fleet
68
+
69
+ fleet = _fleet()
70
+ changed = render_fleet(fleet)
71
+ if changed:
72
+ for rel in changed:
73
+ print(f" wrote deploy/{rel}")
74
+ else:
75
+ print("deploy/ already up to date")
76
+ return 0
77
+
78
+
79
+ def cmd_build(ns: argparse.Namespace) -> int:
80
+ fleet = _fleet()
81
+ image_dir = fleet.root / "image"
82
+ dockerfile = image_dir / "agent.dockerfile"
83
+ if not dockerfile.is_file():
84
+ print(f"{dockerfile} not found -- was this workspace created by `a2y init`?", file=sys.stderr)
85
+ return 1
86
+ cmd = ["docker", "build", "-f", str(dockerfile), "-t", fleet.image_tag]
87
+ if ns.no_cache:
88
+ cmd.append("--no-cache")
89
+ cmd.append(str(image_dir))
90
+ print("+", " ".join(cmd))
91
+ rc = subprocess.call(cmd)
92
+ if rc == 0:
93
+ print(f"Built {fleet.image_tag}. Set A2Y_IMAGE={fleet.image_tag} in deploy/.env.")
94
+ return rc
95
+
96
+
97
+ def cmd_up(ns: argparse.Namespace) -> int:
98
+ from .render import ensure_volumes, render_fleet
99
+
100
+ fleet = _fleet()
101
+ render_fleet(fleet)
102
+ for d in ensure_volumes(fleet):
103
+ print(f" created {d.relative_to(fleet.root)}")
104
+ services = [f"agent-{n}" for n in ns.agents] if ns.agents else []
105
+ return _compose(fleet, "up", "-d", *services)
106
+
107
+
108
+ def cmd_down(ns: argparse.Namespace) -> int:
109
+ fleet = _fleet()
110
+ services = [f"agent-{n}" for n in ns.agents] if ns.agents else []
111
+ return _compose(fleet, "stop", *services)
112
+
113
+
114
+ def cmd_doctor(_: argparse.Namespace) -> int:
115
+ from .doctor import run_doctor
116
+
117
+ return run_doctor(_fleet())
118
+
119
+
120
+ AUTH_TEXT = """\
121
+ === {agent}: signing the brains in ===
122
+
123
+ The logins live in volumes/agent-{agent}/ and survive rebuilds; this is done once
124
+ per agent. Use DEVICE-CODE flows only: browser-callback logins listen on the
125
+ container's own localhost, which your browser cannot reach.
126
+
127
+ docker exec -it agent-{agent} bash
128
+
129
+ {steps}
130
+ Forges (only if this agent uses them):
131
+
132
+ gh auth login --hostname github.com --git-protocol ssh # web-browser one-time code
133
+ tea login add --name <forge> --url <gitea-url> --token <token>
134
+
135
+ Verify with a real turn, not with the login command's exit code: mention the agent
136
+ in its channel and watch the trace. A missing login surfaces as
137
+ `500 Authentication required` from acp2api on the FIRST turn only.
138
+ """
139
+
140
+ AUTH_STEPS = {
141
+ "claude": " claude # then /login -- pick the subscription, it prints a URL and a code\n",
142
+ "codex": " codex login --device-auth\n",
143
+ "opencode": " # opencode: point it at your endpoint in ~/.config/opencode/opencode.json\n",
144
+ "cline": " # cline: requires an interactive cline-account login before ACP works at all\n",
145
+ }
146
+
147
+
148
+ def cmd_auth(ns: argparse.Namespace) -> int:
149
+ fleet = _fleet()
150
+ agents = [a for a in fleet.agents if not ns.agent or a.name == ns.agent]
151
+ if not agents:
152
+ print(f"no agent named {ns.agent!r}", file=sys.stderr)
153
+ return 1
154
+ for a in agents:
155
+ steps = "".join(
156
+ AUTH_STEPS.get(a.executors[ex].get("kind") or ex, "") for ex in a.chain
157
+ )
158
+ print(AUTH_TEXT.format(agent=a.name, steps=steps))
159
+ return 0
160
+
161
+
162
+ PROVISION_MM = """\
163
+ === {agent}: Mattermost account (ordinary user + personal access token) ===
164
+
165
+ Agents are ordinary `system_user` accounts, NOT bot accounts -- Mattermost
166
+ refuses bots several things (incoming webhooks, for one), and a colleague should
167
+ read as a colleague.
168
+
169
+ Via the local admin socket (on the Mattermost host):
170
+
171
+ M="docker exec <mattermost-container> mmctl --local"
172
+ $M user create --email {agent}@{team}.local --username {agent} --password '<strong>'
173
+ $M team users add {team} {agent}
174
+ $M channel users add {team}:<channel> {agent}
175
+ # a personal access token needs the role that permits one:
176
+ # sql: update users set roles='system_user system_user_access_token' where username='{agent}';
177
+ $M token generate {agent} "agent gateway"
178
+
179
+ Or via the v4 REST API (works from anywhere; login_id must be the USERNAME --
180
+ the admin email answers 401 that reads like a wrong password):
181
+
182
+ POST /api/v4/users/login {{"login_id": "<admin-username>", "password": "..."}}
183
+ POST /api/v4/users -> user id
184
+ PUT /api/v4/users/{{id}}/roles {{"roles": "system_user system_user_access_token"}}
185
+ POST /api/v4/teams/<team>/members
186
+ POST /api/v4/channels/<id>/members
187
+ POST /api/v4/users/{{id}}/tokens -> the token, RETURNED ONLY ONCE
188
+
189
+ Then:
190
+ 1. put the token in deploy/.env as {prefix}_MATTERMOST_TOKEN
191
+ 2. append the new USER ID to A2Y_MATTERMOST_ALLOWED_USERS
192
+ 3. recreate the OTHER agents (that list is container environment) -- without
193
+ this, messages from the new colleague are dropped with no error anywhere.
194
+
195
+ Membership POSTs are no-ops when already present, so the sequence is safe to
196
+ re-run after a partial failure.
197
+ """
198
+
199
+
200
+ def cmd_provision(ns: argparse.Namespace) -> int:
201
+ fleet = _fleet()
202
+ if fleet.platform_kind != "mattermost":
203
+ print(f"platform.kind is {fleet.platform_kind!r}; provisioning docs cover mattermost. "
204
+ "For other Hermes platforms create the bot/account per that platform's docs and "
205
+ "pass its variables via platform.env.")
206
+ return 0
207
+ agents = [a for a in fleet.agents if not ns.agent or a.name == ns.agent]
208
+ for a in agents:
209
+ print(PROVISION_MM.format(agent=a.name, team=fleet.platform.get("team", "<team>"),
210
+ prefix=a.env_prefix))
211
+ return 0
212
+
213
+
214
+ def main(argv: list[str] | None = None) -> int:
215
+ parser = argparse.ArgumentParser(prog="a2y", description=__doc__,
216
+ formatter_class=argparse.RawDescriptionHelpFormatter)
217
+ parser.add_argument("--version", action="version", version=f"a2y {__version__}")
218
+ sub = parser.add_subparsers(dest="cmd", required=True)
219
+
220
+ p = sub.add_parser("init", help="create a fleet workspace")
221
+ p.add_argument("dir")
222
+ p.add_argument("--name", help="fleet name (default: directory name)")
223
+ p.add_argument("--agent", default="ana", help="name of the first agent (default: ana)")
224
+ p.set_defaults(fn=cmd_init)
225
+
226
+ from .agents_cmd import register as register_agent_cmd
227
+ register_agent_cmd(sub)
228
+
229
+ p = sub.add_parser("render", help="manifests -> deploy/")
230
+ p.set_defaults(fn=cmd_render)
231
+
232
+ p = sub.add_parser("build", help="build the agent image")
233
+ p.add_argument("--no-cache", action="store_true")
234
+ p.set_defaults(fn=cmd_build)
235
+
236
+ p = sub.add_parser("up", help="ensure volumes, docker compose up -d")
237
+ p.add_argument("agents", nargs="*")
238
+ p.set_defaults(fn=cmd_up)
239
+
240
+ p = sub.add_parser("down", help="docker compose stop")
241
+ p.add_argument("agents", nargs="*")
242
+ p.set_defaults(fn=cmd_down)
243
+
244
+ p = sub.add_parser("doctor", help="check the deployment end to end")
245
+ p.set_defaults(fn=cmd_doctor)
246
+
247
+ p = sub.add_parser("auth", help="print brain sign-in instructions")
248
+ p.add_argument("agent", nargs="?")
249
+ p.set_defaults(fn=cmd_auth)
250
+
251
+ p = sub.add_parser("provision", help="print messenger provisioning sequence")
252
+ p.add_argument("agent", nargs="?")
253
+ p.set_defaults(fn=cmd_provision)
254
+
255
+ ns = parser.parse_args(argv)
256
+ try:
257
+ return ns.fn(ns)
258
+ except ManifestError as exc:
259
+ print(f"a2y: {exc}", file=sys.stderr)
260
+ return 2
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())
a2y/doctor.py ADDED
@@ -0,0 +1,116 @@
1
+ """`a2y doctor` -- say what is wrong before a container has to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from .manifest import Fleet
11
+ from . import render as R
12
+
13
+
14
+ def _env_names(text: str) -> list[str]:
15
+ return [m.group(1) for m in re.finditer(r"^([A-Z][A-Z0-9_]*)=", text, re.M)]
16
+
17
+
18
+ def run_doctor(fleet: Fleet) -> int:
19
+ problems = 0
20
+
21
+ def warn(msg: str) -> None:
22
+ nonlocal problems
23
+ problems += 1
24
+ print(f" ✗ {msg}")
25
+
26
+ def ok(msg: str) -> None:
27
+ print(f" ✓ {msg}")
28
+
29
+ deploy = fleet.root / "deploy"
30
+
31
+ # 1. deploy tree freshness -- render into memory and compare.
32
+ print("deploy tree")
33
+ if not deploy.is_dir():
34
+ warn("deploy/ does not exist -- run `a2y render`")
35
+ else:
36
+ import io
37
+ from contextlib import redirect_stdout
38
+ with redirect_stdout(io.StringIO()):
39
+ changed = R.render_fleet(fleet)
40
+ if changed:
41
+ warn(f"deploy/ was stale; render just refreshed: {', '.join(changed)}")
42
+ else:
43
+ ok("deploy/ matches the manifests")
44
+
45
+ # 2. .env parity -- the invariant that a missing variable can break an
46
+ # UNRELATED service's compose render.
47
+ print("environment")
48
+ example = deploy / "example.env"
49
+ dotenv = deploy / ".env"
50
+ if not dotenv.is_file():
51
+ warn("deploy/.env is missing -- `cp deploy/example.env deploy/.env` and fill it")
52
+ elif example.is_file():
53
+ have = dict.fromkeys(_env_names(dotenv.read_text()))
54
+ missing = [n for n in _env_names(example.read_text()) if n not in have]
55
+ if missing:
56
+ warn(f".env is missing variable(s) from example.env: {', '.join(missing)}")
57
+ else:
58
+ ok(".env carries every variable example.env declares")
59
+ empty = [
60
+ line.split("=", 1)[0]
61
+ for line in dotenv.read_text().splitlines()
62
+ if re.match(r"^[A-Z][A-Z0-9_]*=$", line.strip())
63
+ ]
64
+ if empty:
65
+ print(f" · empty (fill or confirm deliberate): {', '.join(empty)}")
66
+
67
+ # 3. docker
68
+ print("docker")
69
+ if not shutil.which("docker"):
70
+ warn("docker is not on PATH")
71
+ else:
72
+ ok("docker present")
73
+ if dotenv.is_file():
74
+ proc = subprocess.run(
75
+ ["docker", "compose", "-f", str(deploy / "docker-compose.yaml"),
76
+ "--env-file", str(dotenv), "config", "-q"],
77
+ capture_output=True, text=True,
78
+ )
79
+ if proc.returncode != 0:
80
+ warn(f"compose config does not render:\n{proc.stderr.strip()}")
81
+ else:
82
+ ok("docker compose config renders")
83
+
84
+ # 4. volumes
85
+ print("volumes")
86
+ missing_dirs = [
87
+ str(Path("volumes") / a.container)
88
+ for a in fleet.agents
89
+ if not (fleet.root / "volumes" / a.container).is_dir()
90
+ ]
91
+ if missing_dirs:
92
+ print(f" · not created yet (a2y up will): {', '.join(missing_dirs)}")
93
+ else:
94
+ ok("state directories exist")
95
+
96
+ # 5. logins -- the one step nobody can automate.
97
+ print("brains (sign-in state is per volume; empty means not signed in yet)")
98
+ for a in fleet.agents:
99
+ base = fleet.root / "volumes" / a.container
100
+ for ex in a.chain:
101
+ kind = a.executors[ex].get("kind") or ex
102
+ marker = {
103
+ "claude": base / "claude" / ".credentials.json",
104
+ "codex": base / "codex" / "auth.json",
105
+ }.get(kind)
106
+ if marker is None:
107
+ continue
108
+ state = "signed in" if marker.is_file() else "NOT signed in (a2y auth)"
109
+ print(f" · {a.name}/{ex}: {state}")
110
+
111
+ print()
112
+ if problems:
113
+ print(f"{problems} problem(s).")
114
+ return 1
115
+ print("No problems found.")
116
+ return 0