agent-circus 0.1.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.
- agent_circus/__init__.py +5 -0
- agent_circus/agent_config.py +357 -0
- agent_circus/cli.py +73 -0
- agent_circus/commands/__init__.py +5 -0
- agent_circus/commands/build.py +83 -0
- agent_circus/commands/config_store.py +104 -0
- agent_circus/commands/destroy.py +123 -0
- agent_circus/commands/exec_.py +140 -0
- agent_circus/commands/init.py +498 -0
- agent_circus/commands/ps.py +113 -0
- agent_circus/commands/remove.py +127 -0
- agent_circus/commands/up.py +86 -0
- agent_circus/compose.py +481 -0
- agent_circus/config.py +1478 -0
- agent_circus/context.py +599 -0
- agent_circus/exceptions.py +29 -0
- agent_circus/mcp.py +164 -0
- agent_circus/runtime.py +109 -0
- agent_circus/state.py +343 -0
- agent_circus/templates/__init__.py +113 -0
- agent_circus/templates/agent-circus/Dockerfile +164 -0
- agent_circus/templates/agent-circus/compose.yaml +92 -0
- agent_circus/templates/agent-circus/docker-entrypoint.sh +57 -0
- agent_circus/templates/agent-circus/hooks/base-root.sh +3 -0
- agent_circus/templates/agent-circus/hooks/base-user.sh +3 -0
- agent_circus/templates/agent-circus/init-firewall.sh +147 -0
- agent_circus/templates/agent-circus/install-ca-certs.sh +9 -0
- agent_circus/templates/agent-circus/pyproject.toml +16 -0
- agent_circus/templates/agent-circus/setup-claude-mem.sh +24 -0
- agent_circus/templates/agent-circus/uv.lock +1531 -0
- agent_circus/update_versions.py +388 -0
- agent_circus/utils.py +28 -0
- agent_circus-0.1.0.dist-info/METADATA +1195 -0
- agent_circus-0.1.0.dist-info/RECORD +37 -0
- agent_circus-0.1.0.dist-info/WHEEL +4 -0
- agent_circus-0.1.0.dist-info/entry_points.txt +3 -0
- agent_circus-0.1.0.dist-info/licenses/LICENSE +9 -0
agent_circus/__init__.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Agent configuration templating for Agent Circus.
|
|
2
|
+
|
|
3
|
+
Reads each agent's original configuration file, merges in additions
|
|
4
|
+
(e.g. MCP servers), writes the result to the state directory, and
|
|
5
|
+
generates a Docker Compose override that bind-mounts the merged
|
|
6
|
+
files into agent containers.
|
|
7
|
+
|
|
8
|
+
The user's host files are never modified.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import tomllib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
import tomli_w
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Handler protocol
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class AgentConfigHandler(Protocol):
|
|
29
|
+
"""Structural interface for agent-specific configuration handlers."""
|
|
30
|
+
|
|
31
|
+
agent_name: str
|
|
32
|
+
"""Docker Compose service name (e.g. ``"claude-code"``)."""
|
|
33
|
+
|
|
34
|
+
host_config_path: Path
|
|
35
|
+
"""Path to the user's original config file on the host."""
|
|
36
|
+
|
|
37
|
+
container_config_path: str
|
|
38
|
+
"""Absolute path where the config is expected inside the container."""
|
|
39
|
+
|
|
40
|
+
output_filename: str
|
|
41
|
+
"""Filename for the merged config in the state directory."""
|
|
42
|
+
|
|
43
|
+
def read(self) -> dict[str, Any]:
|
|
44
|
+
"""Read and deserialize the user's original config.
|
|
45
|
+
|
|
46
|
+
Returns an empty dict if the file does not exist.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def merge(self, base: dict[str, Any], additions: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""Merge *additions* into *base* config.
|
|
52
|
+
|
|
53
|
+
:param base: The user's original (deserialized) config.
|
|
54
|
+
:param additions: Values to merge in (e.g. ``{"mcp_servers": [...]}``)
|
|
55
|
+
:returns: Merged config.
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def write(self, config: dict[str, Any], output_path: Path) -> None:
|
|
60
|
+
"""Serialize *config* and write it to *output_path*."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_handler(
|
|
65
|
+
handler: AgentConfigHandler,
|
|
66
|
+
additions: dict[str, Any],
|
|
67
|
+
output_dir: Path,
|
|
68
|
+
) -> Path:
|
|
69
|
+
"""Read → merge → write using the given handler.
|
|
70
|
+
|
|
71
|
+
:param handler: Agent config handler to use.
|
|
72
|
+
:param additions: Values to merge into the user's config.
|
|
73
|
+
:param output_dir: Directory to write the merged config to.
|
|
74
|
+
:returns: Path to the written file.
|
|
75
|
+
"""
|
|
76
|
+
base = handler.read()
|
|
77
|
+
merged = handler.merge(base, additions)
|
|
78
|
+
output_path = output_dir / handler.output_filename
|
|
79
|
+
handler.write(merged, output_path)
|
|
80
|
+
logger.debug("Built merged config for %s: %s", handler.agent_name, output_path)
|
|
81
|
+
return output_path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# JSON-based handlers (Claude Code, OpenCode)
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ClaudeCodeConfigHandler:
|
|
90
|
+
"""Handler for Claude Code configuration (JSON)."""
|
|
91
|
+
|
|
92
|
+
agent_name = "claude-code"
|
|
93
|
+
container_config_path = "/home/node/.claude/.claude.json"
|
|
94
|
+
output_filename = "claude-code.json"
|
|
95
|
+
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self.host_config_path = Path.home() / ".claude" / ".claude.json"
|
|
98
|
+
|
|
99
|
+
def read(self) -> dict[str, Any]:
|
|
100
|
+
if not self.host_config_path.is_file():
|
|
101
|
+
return {}
|
|
102
|
+
with open(self.host_config_path) as f:
|
|
103
|
+
return json.load(f)
|
|
104
|
+
|
|
105
|
+
def merge(self, base: dict[str, Any], additions: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
merged = base.copy()
|
|
107
|
+
for key, value in additions.items():
|
|
108
|
+
if (
|
|
109
|
+
key in merged
|
|
110
|
+
and isinstance(merged[key], dict)
|
|
111
|
+
and isinstance(value, dict)
|
|
112
|
+
):
|
|
113
|
+
# Dict-level merge (e.g. mcpServers): additions overwrite conflicts.
|
|
114
|
+
merged[key] = {**merged[key], **value}
|
|
115
|
+
else:
|
|
116
|
+
merged[key] = value
|
|
117
|
+
return merged
|
|
118
|
+
|
|
119
|
+
def write(self, config: dict[str, Any], output_path: Path) -> None:
|
|
120
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with open(output_path, "w") as f:
|
|
122
|
+
json.dump(config, f, indent=2)
|
|
123
|
+
f.write("\n")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class OpenCodeConfigHandler:
|
|
127
|
+
"""Handler for OpenCode configuration (JSON)."""
|
|
128
|
+
|
|
129
|
+
agent_name = "opencode"
|
|
130
|
+
container_config_path = "/home/node/.config/opencode/opencode.json"
|
|
131
|
+
output_filename = "opencode.json"
|
|
132
|
+
|
|
133
|
+
def __init__(self) -> None:
|
|
134
|
+
self.host_config_path = Path.home() / ".config" / "opencode" / "opencode.json"
|
|
135
|
+
|
|
136
|
+
def read(self) -> dict[str, Any]:
|
|
137
|
+
if not self.host_config_path.is_file():
|
|
138
|
+
return {}
|
|
139
|
+
with open(self.host_config_path) as f:
|
|
140
|
+
return json.load(f)
|
|
141
|
+
|
|
142
|
+
def merge(self, base: dict[str, Any], additions: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
merged = base.copy()
|
|
144
|
+
for key, value in additions.items():
|
|
145
|
+
if (
|
|
146
|
+
key in merged
|
|
147
|
+
and isinstance(merged[key], dict)
|
|
148
|
+
and isinstance(value, dict)
|
|
149
|
+
):
|
|
150
|
+
# Dict-level merge (e.g. mcp): additions overwrite conflicts.
|
|
151
|
+
merged[key] = {**merged[key], **value}
|
|
152
|
+
else:
|
|
153
|
+
merged[key] = value
|
|
154
|
+
return merged
|
|
155
|
+
|
|
156
|
+
def write(self, config: dict[str, Any], output_path: Path) -> None:
|
|
157
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
with open(output_path, "w") as f:
|
|
159
|
+
json.dump(config, f, indent=2)
|
|
160
|
+
f.write("\n")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# TOML-based handlers (Codex, Vibe)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _TomlConfigHandler:
|
|
169
|
+
"""Base handler for TOML-based agent configs (Codex, Vibe)."""
|
|
170
|
+
|
|
171
|
+
host_config_path: Path
|
|
172
|
+
|
|
173
|
+
def read(self) -> dict[str, Any]:
|
|
174
|
+
if not self.host_config_path.is_file():
|
|
175
|
+
return {}
|
|
176
|
+
with open(self.host_config_path, "rb") as f:
|
|
177
|
+
return tomllib.load(f)
|
|
178
|
+
|
|
179
|
+
def merge(self, base: dict[str, Any], additions: dict[str, Any]) -> dict[str, Any]:
|
|
180
|
+
merged = base.copy()
|
|
181
|
+
for key, value in additions.items():
|
|
182
|
+
if (
|
|
183
|
+
key in merged
|
|
184
|
+
and isinstance(merged[key], list)
|
|
185
|
+
and isinstance(value, list)
|
|
186
|
+
):
|
|
187
|
+
# Array merge by "name" field: additions overwrite entries
|
|
188
|
+
# with the same name, new entries are appended.
|
|
189
|
+
merged[key] = _merge_named_arrays(merged[key], value)
|
|
190
|
+
elif (
|
|
191
|
+
key in merged
|
|
192
|
+
and isinstance(merged[key], dict)
|
|
193
|
+
and isinstance(value, dict)
|
|
194
|
+
):
|
|
195
|
+
merged[key] = {**merged[key], **value}
|
|
196
|
+
else:
|
|
197
|
+
merged[key] = value
|
|
198
|
+
return merged
|
|
199
|
+
|
|
200
|
+
def write(self, config: dict[str, Any], output_path: Path) -> None:
|
|
201
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
with open(output_path, "wb") as f:
|
|
203
|
+
tomli_w.dump(config, f)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class CodexConfigHandler(_TomlConfigHandler):
|
|
207
|
+
"""Handler for Codex configuration (TOML)."""
|
|
208
|
+
|
|
209
|
+
agent_name = "codex"
|
|
210
|
+
container_config_path = "/home/node/.codex/config.toml"
|
|
211
|
+
output_filename = "codex.toml"
|
|
212
|
+
|
|
213
|
+
def __init__(self) -> None:
|
|
214
|
+
self.host_config_path = Path.home() / ".codex" / "config.toml"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class VibeConfigHandler(_TomlConfigHandler):
|
|
218
|
+
"""Handler for Mistral Vibe configuration (TOML)."""
|
|
219
|
+
|
|
220
|
+
agent_name = "mistral-vibe"
|
|
221
|
+
container_config_path = "/home/node/.vibe/config.toml"
|
|
222
|
+
output_filename = "mistral-vibe.toml"
|
|
223
|
+
|
|
224
|
+
def __init__(self) -> None:
|
|
225
|
+
self.host_config_path = Path.home() / ".vibe" / "config.toml"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Merge helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _merge_named_arrays(
|
|
234
|
+
base: list[dict[str, Any]], additions: list[dict[str, Any]]
|
|
235
|
+
) -> list[dict[str, Any]]:
|
|
236
|
+
"""Merge two lists of dicts by the ``name`` field.
|
|
237
|
+
|
|
238
|
+
Entries in *additions* overwrite base entries with the same name.
|
|
239
|
+
New entries are appended. Order: base entries first (updated
|
|
240
|
+
in-place), then new additions.
|
|
241
|
+
|
|
242
|
+
Falls back to simple concatenation if entries lack ``name`` fields.
|
|
243
|
+
|
|
244
|
+
:param base: Original list of entries.
|
|
245
|
+
:param additions: Entries to merge in.
|
|
246
|
+
:returns: Merged list.
|
|
247
|
+
"""
|
|
248
|
+
# Check if entries have "name" fields for keyed merging.
|
|
249
|
+
if not all(isinstance(e, dict) and "name" in e for e in [*base, *additions]):
|
|
250
|
+
return [*base, *additions]
|
|
251
|
+
|
|
252
|
+
by_name: dict[str, dict[str, Any]] = {e["name"]: e for e in base}
|
|
253
|
+
order: list[str] = [e["name"] for e in base]
|
|
254
|
+
|
|
255
|
+
for entry in additions:
|
|
256
|
+
name = entry["name"]
|
|
257
|
+
if name not in by_name:
|
|
258
|
+
order.append(name)
|
|
259
|
+
by_name[name] = entry
|
|
260
|
+
|
|
261
|
+
return [by_name[name] for name in order]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# All handlers
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
HANDLERS: list[type[AgentConfigHandler]] = [
|
|
270
|
+
ClaudeCodeConfigHandler,
|
|
271
|
+
CodexConfigHandler,
|
|
272
|
+
VibeConfigHandler,
|
|
273
|
+
OpenCodeConfigHandler,
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Compose override generation
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def build_agent_configs_override(
|
|
283
|
+
additions: dict[str, dict[str, Any]],
|
|
284
|
+
output_dir: Path,
|
|
285
|
+
excluded_agents: set[str] | None = None,
|
|
286
|
+
) -> str:
|
|
287
|
+
"""Build merged agent configs and return a Compose override JSON string.
|
|
288
|
+
|
|
289
|
+
For each agent, reads the user's original config, merges in
|
|
290
|
+
*additions*, writes the result to *output_dir*, and produces a
|
|
291
|
+
Compose override that bind-mounts the merged files into the
|
|
292
|
+
corresponding containers.
|
|
293
|
+
|
|
294
|
+
:param additions: Per-agent additions dict, keyed by agent name.
|
|
295
|
+
Example::
|
|
296
|
+
|
|
297
|
+
{
|
|
298
|
+
"claude-code": {"mcpServers": {...}},
|
|
299
|
+
"codex": {"mcp_servers": [...]},
|
|
300
|
+
"mistral-vibe": {"mcp_servers": [...]},
|
|
301
|
+
"opencode": {"mcp": {...}},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
:param output_dir: Directory to write merged config files to.
|
|
305
|
+
:type output_dir: Path
|
|
306
|
+
:param excluded_agents: Agents whose config is managed through another
|
|
307
|
+
writable mount, such as a project data store.
|
|
308
|
+
:returns: Compose override as a JSON string.
|
|
309
|
+
:rtype: str
|
|
310
|
+
"""
|
|
311
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
services: dict[str, Any] = {}
|
|
313
|
+
excluded_agents = excluded_agents or set()
|
|
314
|
+
|
|
315
|
+
for handler_cls in HANDLERS:
|
|
316
|
+
handler = handler_cls()
|
|
317
|
+
if handler.agent_name in excluded_agents:
|
|
318
|
+
continue
|
|
319
|
+
agent_additions = additions.get(handler.agent_name, {})
|
|
320
|
+
if not agent_additions:
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
output_path = build_handler(handler, agent_additions, output_dir)
|
|
324
|
+
services[handler.agent_name] = {
|
|
325
|
+
"volumes": [
|
|
326
|
+
f"{output_path}:{handler.container_config_path}",
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return json.dumps({"services": services})
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def merge_agent_config_store(
|
|
334
|
+
agent_name: str,
|
|
335
|
+
additions: dict[str, Any],
|
|
336
|
+
store_dir: Path,
|
|
337
|
+
) -> Path:
|
|
338
|
+
"""Merge generated additions into an agent config data store.
|
|
339
|
+
|
|
340
|
+
:param agent_name: Agent service whose native config should be updated.
|
|
341
|
+
:param additions: Values to merge into the native agent configuration.
|
|
342
|
+
:param store_dir: Host directory mounted at the agent's config directory.
|
|
343
|
+
:returns: Path to the updated native configuration file.
|
|
344
|
+
:raises ValueError: If ``agent_name`` has no configuration handler.
|
|
345
|
+
"""
|
|
346
|
+
for handler_cls in HANDLERS:
|
|
347
|
+
handler = handler_cls()
|
|
348
|
+
if handler.agent_name != agent_name:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
config_path = store_dir / Path(handler.container_config_path).name
|
|
352
|
+
handler.host_config_path = config_path
|
|
353
|
+
merged = handler.merge(handler.read(), additions)
|
|
354
|
+
handler.write(merged, config_path)
|
|
355
|
+
return config_path
|
|
356
|
+
|
|
357
|
+
raise ValueError(f"Unsupported agent config store: {agent_name}")
|
agent_circus/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Main CLI entry point for Agent Circus."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from agent_circus.commands import (
|
|
9
|
+
build,
|
|
10
|
+
config_store,
|
|
11
|
+
destroy,
|
|
12
|
+
exec_,
|
|
13
|
+
init,
|
|
14
|
+
ps,
|
|
15
|
+
remove,
|
|
16
|
+
up,
|
|
17
|
+
)
|
|
18
|
+
from agent_circus.config import load_user_config
|
|
19
|
+
from agent_circus.utils import setup_logging
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="agent-circus",
|
|
23
|
+
help="CLI for managing agent containers.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.callback()
|
|
29
|
+
def main(
|
|
30
|
+
log_level: Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--log-level",
|
|
34
|
+
envvar="LOGLEVEL",
|
|
35
|
+
help="Set the log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).",
|
|
36
|
+
),
|
|
37
|
+
] = None,
|
|
38
|
+
log_file: Annotated[
|
|
39
|
+
Path | None,
|
|
40
|
+
typer.Option(
|
|
41
|
+
"--log-file",
|
|
42
|
+
envvar="LOGFILE",
|
|
43
|
+
help="Path to a log file. Logs are written to both stdout and this file.",
|
|
44
|
+
),
|
|
45
|
+
] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""CLI for managing agent containers."""
|
|
48
|
+
logging_cfg = load_user_config().get("logging", {})
|
|
49
|
+
setup_logging(
|
|
50
|
+
level=log_level or logging_cfg.get("level", "INFO"),
|
|
51
|
+
log_file=log_file
|
|
52
|
+
or (Path(logging_cfg["file"]) if logging_cfg.get("file") else None),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
app.command()(init.init)
|
|
57
|
+
app.command()(build.build)
|
|
58
|
+
app.command()(up.up)
|
|
59
|
+
app.command()(ps.ps)
|
|
60
|
+
app.command(name="exec")(exec_.exec_cmd)
|
|
61
|
+
app.command()(remove.remove)
|
|
62
|
+
app.command(name="rm", hidden=True)(remove.remove)
|
|
63
|
+
app.command()(destroy.destroy)
|
|
64
|
+
app.add_typer(config_store.app, name="config")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_cli() -> None:
|
|
68
|
+
"""Entry point for the CLI."""
|
|
69
|
+
app()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
run_cli()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Build agent container images."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from agent_circus.compose import compose_build
|
|
10
|
+
from agent_circus.config import (
|
|
11
|
+
AVAILABLE_SERVICES,
|
|
12
|
+
get_workspace_path,
|
|
13
|
+
validate_services,
|
|
14
|
+
)
|
|
15
|
+
from agent_circus.context import build_compose_context
|
|
16
|
+
from agent_circus.exceptions import AgentCircusError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build(
|
|
22
|
+
services: Annotated[
|
|
23
|
+
list[str] | None,
|
|
24
|
+
typer.Argument(
|
|
25
|
+
help=f"Services to build. Available: {', '.join(AVAILABLE_SERVICES)}",
|
|
26
|
+
),
|
|
27
|
+
] = None,
|
|
28
|
+
workspace: Annotated[
|
|
29
|
+
Path | None,
|
|
30
|
+
typer.Option(
|
|
31
|
+
"--workspace",
|
|
32
|
+
"-w",
|
|
33
|
+
help="Workspace directory path.",
|
|
34
|
+
exists=True,
|
|
35
|
+
file_okay=False,
|
|
36
|
+
resolve_path=True,
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
no_cache: Annotated[
|
|
40
|
+
bool,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--no-cache",
|
|
43
|
+
help="Build without using cache.",
|
|
44
|
+
),
|
|
45
|
+
] = False,
|
|
46
|
+
runtime: Annotated[
|
|
47
|
+
str | None,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--runtime",
|
|
50
|
+
help="Container runtime backend to use: docker or podman.",
|
|
51
|
+
),
|
|
52
|
+
] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Build agent container images.
|
|
55
|
+
|
|
56
|
+
Builds container images for the specified services using the selected
|
|
57
|
+
runtime's Compose implementation.
|
|
58
|
+
If no services are specified, all services will be built.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
agent-circus build # Build all services
|
|
62
|
+
agent-circus build claude-code # Build only claude-code
|
|
63
|
+
agent-circus build codex mistral-vibe # Build multiple services
|
|
64
|
+
agent-circus build --no-cache # Build without cache
|
|
65
|
+
"""
|
|
66
|
+
workspace = workspace or get_workspace_path()
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
services_to_build = validate_services(services or [])
|
|
70
|
+
|
|
71
|
+
if services:
|
|
72
|
+
typer.echo(f"Building services: {', '.join(services_to_build)}")
|
|
73
|
+
else:
|
|
74
|
+
typer.echo("Building all services...")
|
|
75
|
+
|
|
76
|
+
context_kwargs = {"runtime": runtime} if runtime is not None else {}
|
|
77
|
+
with build_compose_context(workspace, **context_kwargs) as ctx:
|
|
78
|
+
compose_build(ctx, services_to_build, no_cache=no_cache)
|
|
79
|
+
typer.echo("Build completed successfully.")
|
|
80
|
+
|
|
81
|
+
except AgentCircusError as e:
|
|
82
|
+
typer.echo(f"Error: {e}", err=True)
|
|
83
|
+
raise typer.Exit(code=1) from e
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Manage project-local writable agent configuration stores."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from agent_circus.compose import compose_is_service_running
|
|
10
|
+
from agent_circus.config import (
|
|
11
|
+
AVAILABLE_SERVICES,
|
|
12
|
+
get_agent_config_data_stores,
|
|
13
|
+
get_workspace_path,
|
|
14
|
+
load_config,
|
|
15
|
+
validate_services,
|
|
16
|
+
)
|
|
17
|
+
from agent_circus.context import build_compose_context
|
|
18
|
+
from agent_circus.exceptions import AgentCircusError, ConfigurationError
|
|
19
|
+
from agent_circus.state import get_agent_config_store_dir, get_data_store_dir
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Manage writable agent configuration stores.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _config_store_path(workspace: Path, agent_name: str) -> Path:
|
|
25
|
+
"""Return the active writable config store path for an agent.
|
|
26
|
+
|
|
27
|
+
:param workspace: Workspace directory.
|
|
28
|
+
:param agent_name: Agent service name.
|
|
29
|
+
:returns: Built-in or explicitly configured store path.
|
|
30
|
+
"""
|
|
31
|
+
owners = get_agent_config_data_stores(load_config(workspace).get("data_stores", []))
|
|
32
|
+
explicit_store = owners.get(agent_name)
|
|
33
|
+
if explicit_store:
|
|
34
|
+
return get_data_store_dir(workspace, explicit_store)
|
|
35
|
+
return get_agent_config_store_dir(workspace, agent_name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def reset(
|
|
40
|
+
agent: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Argument(
|
|
43
|
+
help=f"Agent to reset. Available: {', '.join(AVAILABLE_SERVICES)}"
|
|
44
|
+
),
|
|
45
|
+
] = None,
|
|
46
|
+
all_agents: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option("--all", help="Reset configuration stores for every agent."),
|
|
49
|
+
] = False,
|
|
50
|
+
workspace: Annotated[
|
|
51
|
+
Path | None,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--workspace",
|
|
54
|
+
"-w",
|
|
55
|
+
help="Workspace directory path.",
|
|
56
|
+
exists=True,
|
|
57
|
+
file_okay=False,
|
|
58
|
+
resolve_path=True,
|
|
59
|
+
),
|
|
60
|
+
] = None,
|
|
61
|
+
force: Annotated[
|
|
62
|
+
bool,
|
|
63
|
+
typer.Option("--force", "-f", help="Skip the confirmation prompt."),
|
|
64
|
+
] = False,
|
|
65
|
+
runtime: Annotated[
|
|
66
|
+
str | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--runtime",
|
|
69
|
+
help="Container runtime backend to use: docker or podman.",
|
|
70
|
+
),
|
|
71
|
+
] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Delete writable config stores so the next start seeds them again."""
|
|
74
|
+
workspace = workspace or get_workspace_path()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
if all_agents == (agent is not None):
|
|
78
|
+
raise ConfigurationError("Specify exactly one agent or --all")
|
|
79
|
+
agents = AVAILABLE_SERVICES if all_agents else validate_services([agent or ""])
|
|
80
|
+
|
|
81
|
+
context_kwargs = {"runtime": runtime} if runtime is not None else {}
|
|
82
|
+
with build_compose_context(workspace, **context_kwargs) as ctx:
|
|
83
|
+
running = [name for name in agents if compose_is_service_running(ctx, name)]
|
|
84
|
+
if running:
|
|
85
|
+
raise ConfigurationError(
|
|
86
|
+
"Cannot reset config for running services: " + ", ".join(running)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if not force:
|
|
90
|
+
typer.echo(
|
|
91
|
+
"This will delete writable config for: " + ", ".join(agents) + "."
|
|
92
|
+
)
|
|
93
|
+
if not typer.confirm("Are you sure you want to continue?"):
|
|
94
|
+
typer.echo("Aborted.")
|
|
95
|
+
raise typer.Exit(code=0)
|
|
96
|
+
|
|
97
|
+
for name in agents:
|
|
98
|
+
store_path = _config_store_path(workspace, name)
|
|
99
|
+
if store_path.exists():
|
|
100
|
+
shutil.rmtree(store_path)
|
|
101
|
+
typer.echo(f"Reset {name} config store: {store_path}")
|
|
102
|
+
except AgentCircusError as e:
|
|
103
|
+
typer.echo(f"Error: {e}", err=True)
|
|
104
|
+
raise typer.Exit(code=1) from e
|