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 +3 -0
- a2y/agents_cmd.py +184 -0
- a2y/cli.py +264 -0
- a2y/doctor.py +116 -0
- a2y/image/agent.dockerfile +215 -0
- a2y/image/apply-memory-profile.py +123 -0
- a2y/image/bashrc +39 -0
- a2y/image/entrypoint.sh +282 -0
- a2y/image/fleet-roster.py +124 -0
- a2y/image/plugins/_.hint +63 -0
- a2y/image/plugins/conversation-key/__init__.py +61 -0
- a2y/image/plugins/conversation-key/plugin.yaml +8 -0
- a2y/image/plugins/hermes_otel/config.yaml +62 -0
- a2y/image/plugins/mention-on-edit/__init__.py +301 -0
- a2y/image/plugins/mention-on-edit/plugin.yaml +7 -0
- a2y/image/plugins/reasoning-live/__init__.py +435 -0
- a2y/image/plugins/reasoning-live/plugin.yaml +9 -0
- a2y/image/plugins/steer-into-turn/__init__.py +419 -0
- a2y/image/plugins/steer-into-turn/plugin.yaml +9 -0
- a2y/image/plugins/trace-to-card/__init__.py +329 -0
- a2y/image/plugins/trace-to-card/plugin.yaml +12 -0
- a2y/image/plugins/untagged-routing/__init__.py +329 -0
- a2y/image/plugins/untagged-routing/plugin.yaml +12 -0
- a2y/image/plugins/untagged-routing/policy.py +160 -0
- a2y/image/supervisord.conf +83 -0
- a2y/manifest.py +278 -0
- a2y/render.py +633 -0
- a2y/scaffold.py +159 -0
- agent2you-1.0.0.dist-info/METADATA +163 -0
- agent2you-1.0.0.dist-info/RECORD +34 -0
- agent2you-1.0.0.dist-info/WHEEL +5 -0
- agent2you-1.0.0.dist-info/entry_points.txt +2 -0
- agent2you-1.0.0.dist-info/licenses/LICENSE +21 -0
- agent2you-1.0.0.dist-info/top_level.txt +1 -0
a2y/__init__.py
ADDED
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
|