agentship-cli 0.0.1__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.
- agentship_cli/main.py +1023 -0
- agentship_cli/migrations.py +82 -0
- agentship_cli/py.typed +0 -0
- agentship_cli/scaffold.py +271 -0
- agentship_cli/verify.py +391 -0
- agentship_cli-0.0.1.dist-info/METADATA +25 -0
- agentship_cli-0.0.1.dist-info/RECORD +9 -0
- agentship_cli-0.0.1.dist-info/WHEEL +4 -0
- agentship_cli-0.0.1.dist-info/entry_points.txt +2 -0
agentship_cli/main.py
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
"""The ``agentship`` command-line interface.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over the harness. It offers:
|
|
4
|
+
|
|
5
|
+
- ``agentship run <file> --input ... [--stream]`` — load a YAML spec, build the
|
|
6
|
+
agent, run (or stream) one turn, and print the output.
|
|
7
|
+
- ``agentship doctor [--agents-dir DIR | FILE]`` — validate agent specs against
|
|
8
|
+
their engines' declared capabilities, with actionable install hints when an
|
|
9
|
+
engine's package is not installed.
|
|
10
|
+
- ``agentship init [DIR]`` — scaffold a new single-tenant project.
|
|
11
|
+
- ``agentship new-agent NAME`` — scaffold one starter agent spec.
|
|
12
|
+
- ``agentship db upgrade [--allow-migrations]`` — the single, gated owning
|
|
13
|
+
entry point for all schema migrations (DDL). Plan-only by default; only
|
|
14
|
+
``--allow-migrations`` may apply anything (§13.9).
|
|
15
|
+
|
|
16
|
+
Harness errors are caught and printed as a clean message with a non-zero exit
|
|
17
|
+
code rather than a traceback.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
from agentship.engines.base import ENGINES, assert_spec_supported
|
|
31
|
+
from agentship.errors import AgentShipError
|
|
32
|
+
from agentship.logs import configure_logging
|
|
33
|
+
from agentship.runtime import build_agent
|
|
34
|
+
from agentship.spec import load_spec
|
|
35
|
+
|
|
36
|
+
from . import scaffold
|
|
37
|
+
from .migrations import REGISTERED_MIGRATIONS, Migration
|
|
38
|
+
|
|
39
|
+
#: Environment variables consulted (in order) for the database DSN when
|
|
40
|
+
#: ``--database-url`` is not given. ``AGENTSHIP_DATABASE_URL`` is preferred; a
|
|
41
|
+
#: generic ``DATABASE_URL`` is accepted as a fallback for common deployments.
|
|
42
|
+
DATABASE_URL_ENV_VARS = ("AGENTSHIP_DATABASE_URL", "DATABASE_URL")
|
|
43
|
+
|
|
44
|
+
#: Known engine name → the ``pip install`` target that provides it. Used by
|
|
45
|
+
#: ``doctor`` to turn "engine not installed" into an actionable fix instead of an
|
|
46
|
+
#: opaque lookup miss. An engine not in this map gets a generic hint naming the
|
|
47
|
+
#: conventional ``agentship-<name>`` package.
|
|
48
|
+
ENGINE_PIP_TARGET = {
|
|
49
|
+
"langgraph": "agentship-sdk[langgraph]",
|
|
50
|
+
"pydantic-ai": "agentship-pydantic-ai",
|
|
51
|
+
"pydantic_ai": "agentship-pydantic-ai",
|
|
52
|
+
"adk": "agentship-adk",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _install_hint(engine: str) -> str:
|
|
57
|
+
"""Return the ``pip install`` command that provides ``engine``.
|
|
58
|
+
|
|
59
|
+
Looks the engine up in :data:`ENGINE_PIP_TARGET`; falls back to the
|
|
60
|
+
conventional ``agentship-<engine>`` package name for an unknown engine so the
|
|
61
|
+
message is always actionable rather than an opaque lookup miss.
|
|
62
|
+
"""
|
|
63
|
+
target = ENGINE_PIP_TARGET.get(engine, f"agentship-{engine}")
|
|
64
|
+
return f"pip install {target}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_env_for_run(env_file: str | None) -> None:
|
|
68
|
+
"""Load provider credentials from a ``.env`` for a real ``agentship run``.
|
|
69
|
+
|
|
70
|
+
Reads environment variables from a ``.env`` file so a user can supply a key
|
|
71
|
+
like ``OPENAI_API_KEY`` without exporting it by hand. This is called only from
|
|
72
|
+
inside a command function — never at import time — so importing ``agentship``
|
|
73
|
+
or running the test suite never pulls a ``.env`` into the environment (which is
|
|
74
|
+
what once caused tests to fire stray paid provider calls).
|
|
75
|
+
|
|
76
|
+
When ``env_file`` is given it must exist: a missing path raises
|
|
77
|
+
:class:`~agentship.errors.AgentShipError` so the CLI reports a clean ``Error:``
|
|
78
|
+
rather than silently ignoring a typo. When it is ``None`` the current
|
|
79
|
+
directory's ``.env`` is loaded if present (and skipped silently if absent).
|
|
80
|
+
|
|
81
|
+
Loading uses ``override=False`` so a variable already exported in the real
|
|
82
|
+
environment always wins over the ``.env`` value.
|
|
83
|
+
"""
|
|
84
|
+
from dotenv import load_dotenv
|
|
85
|
+
|
|
86
|
+
if env_file is not None:
|
|
87
|
+
path = Path(env_file)
|
|
88
|
+
if not path.is_file():
|
|
89
|
+
raise AgentShipError(f"--env-file not found: {env_file}")
|
|
90
|
+
load_dotenv(dotenv_path=path, override=False)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
default = Path.cwd() / ".env"
|
|
94
|
+
if default.is_file():
|
|
95
|
+
load_dotenv(dotenv_path=default, override=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@click.group()
|
|
99
|
+
def main() -> None:
|
|
100
|
+
"""AgentShip — run agents authored in YAML or Python."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@main.command()
|
|
104
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False))
|
|
105
|
+
@click.option("--input", "input_text", required=True, help="The input text for one turn.")
|
|
106
|
+
@click.option("--stream", is_flag=True, help="Stream the response as events instead of one result.")
|
|
107
|
+
@click.option(
|
|
108
|
+
"--env-file",
|
|
109
|
+
"env_file",
|
|
110
|
+
default=None,
|
|
111
|
+
help="Load environment variables from this .env file (defaults to ./.env if present).",
|
|
112
|
+
)
|
|
113
|
+
@click.option("--debug", is_flag=True, help="Re-raise on failure so the full traceback is shown.")
|
|
114
|
+
@click.option(
|
|
115
|
+
"--verbose",
|
|
116
|
+
"-v",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
help="Show the agent's internal decision logs at INFO (routing, tool calls, checkpoints).",
|
|
119
|
+
)
|
|
120
|
+
def run(
|
|
121
|
+
file: str,
|
|
122
|
+
input_text: str,
|
|
123
|
+
stream: bool,
|
|
124
|
+
env_file: str | None,
|
|
125
|
+
debug: bool,
|
|
126
|
+
verbose: bool,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Run one turn of the agent declared in FILE and print its output.
|
|
129
|
+
|
|
130
|
+
Before running, environment variables are loaded from a ``.env`` — the current
|
|
131
|
+
directory's ``.env`` by default, or the file named by ``--env-file`` — so a
|
|
132
|
+
provider key such as ``OPENAI_API_KEY`` need not be exported by hand. An
|
|
133
|
+
already-exported variable is never overwritten by the ``.env``.
|
|
134
|
+
|
|
135
|
+
With ``--verbose`` the agent's internal decision log is printed to stderr at INFO
|
|
136
|
+
level — colorized and leveled — covering engine build, MCP discovery, tool calls,
|
|
137
|
+
checkpoint/resume, and (for a supervisor) the classify → route → dispatch → resolve
|
|
138
|
+
trace. ``--debug`` raises the log level to DEBUG (and re-raises on failure for the
|
|
139
|
+
full traceback). Only stdout carries the final answer, so neither flag pollutes a
|
|
140
|
+
piped result.
|
|
141
|
+
|
|
142
|
+
On failure the CLI prints a single clean ``Error: …`` line to stderr and exits
|
|
143
|
+
``1`` — never a raw traceback. Known harness failures
|
|
144
|
+
(:class:`~agentship.errors.AgentShipError` and its subclasses, e.g.
|
|
145
|
+
``ModelError`` / ``SpecError`` / ``CapabilityError``) print their actionable
|
|
146
|
+
message as-is. Any unexpected error prints its concise message plus a hint to
|
|
147
|
+
re-run with ``--debug``. With ``--debug`` set, the original exception is
|
|
148
|
+
re-raised so the full traceback surfaces for diagnosis.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
load_env_for_run(env_file)
|
|
152
|
+
if debug:
|
|
153
|
+
configure_logging(logging.DEBUG)
|
|
154
|
+
elif verbose:
|
|
155
|
+
configure_logging(logging.INFO)
|
|
156
|
+
agent = build_agent(file)
|
|
157
|
+
if stream:
|
|
158
|
+
asyncio.run(_stream_turn(agent, input_text))
|
|
159
|
+
else:
|
|
160
|
+
result = asyncio.run(agent.run(input_text))
|
|
161
|
+
click.echo(result.output)
|
|
162
|
+
except AgentShipError as exc:
|
|
163
|
+
# Expected harness failure: already carries an actionable message.
|
|
164
|
+
if debug:
|
|
165
|
+
raise
|
|
166
|
+
click.echo(f"Error: {exc}", err=True)
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
# Unexpected failure: show a concise message, not a wall of traceback,
|
|
170
|
+
# and point at --debug for the full trace.
|
|
171
|
+
if debug:
|
|
172
|
+
raise
|
|
173
|
+
click.echo(f"Error: {exc} (run with --debug for the full traceback)", err=True)
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _stream_turn(agent, input_text: str) -> None:
|
|
178
|
+
"""Drive the agent's stream, printing each content chunk as it arrives.
|
|
179
|
+
|
|
180
|
+
Content events are printed inline; the terminal ``done`` event ends the line.
|
|
181
|
+
"""
|
|
182
|
+
async for event in agent.stream(input_text):
|
|
183
|
+
if event.type == "content" and event.data is not None:
|
|
184
|
+
click.echo(event.data, nl=False)
|
|
185
|
+
elif event.type == "done":
|
|
186
|
+
click.echo() # newline terminating the streamed line
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _check_agent(path: Path) -> str | None:
|
|
190
|
+
"""Validate one agent spec; return an error reason string, or ``None`` if OK.
|
|
191
|
+
|
|
192
|
+
Loads the YAML into an :class:`~agentship.spec.AgentSpec`, resolves its engine
|
|
193
|
+
from the :data:`~agentship.engines.base.ENGINES` registry, and runs the
|
|
194
|
+
capability gate (:func:`~agentship.engines.base.assert_spec_supported`). Every
|
|
195
|
+
expected failure is turned into a clean one-line reason (never a traceback):
|
|
196
|
+
|
|
197
|
+
- a missing file / malformed YAML / unknown field raises ``SpecError`` →
|
|
198
|
+
its actionable message;
|
|
199
|
+
- an engine whose package is not installed → a reason naming the exact
|
|
200
|
+
``pip install`` fix (via :func:`_install_hint`), not an opaque lookup miss;
|
|
201
|
+
- a capability mismatch raises ``CapabilityError`` → its actionable message.
|
|
202
|
+
|
|
203
|
+
Any :class:`~agentship.errors.AgentShipError` is caught here so the caller can
|
|
204
|
+
print a status line; unexpected errors propagate to be handled once at the
|
|
205
|
+
command level (and re-raised under ``--debug``).
|
|
206
|
+
"""
|
|
207
|
+
spec = load_spec(path) # SpecError on bad YAML / unknown field — caught by caller
|
|
208
|
+
if spec.engine not in ENGINES:
|
|
209
|
+
return (
|
|
210
|
+
f"engine {spec.engine!r} not installed — {_install_hint(spec.engine)} "
|
|
211
|
+
f"(installed: {ENGINES.names()})"
|
|
212
|
+
)
|
|
213
|
+
engine = ENGINES.get(spec.engine)()
|
|
214
|
+
assert_spec_supported(engine, spec) # CapabilityError on mismatch — caught by caller
|
|
215
|
+
autonomous_reason = _check_autonomous_version(spec)
|
|
216
|
+
if autonomous_reason is not None:
|
|
217
|
+
return autonomous_reason
|
|
218
|
+
mcp_reason = _check_mcp_version(spec)
|
|
219
|
+
if mcp_reason is not None:
|
|
220
|
+
return mcp_reason
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _check_mcp_version(spec) -> str | None:
|
|
225
|
+
"""Guard an agent that declares ``mcp:`` servers against a missing/out-of-range ``mcp`` SDK.
|
|
226
|
+
|
|
227
|
+
Returns an actionable reason when the ``[mcp]`` extra is not installed or the installed ``mcp``
|
|
228
|
+
version is outside the supported ``>=1.28,<2`` range, or ``None`` when the spec has no ``mcp:``
|
|
229
|
+
servers or the install is fine. Never raises — if the langgraph adapter is not importable here
|
|
230
|
+
the guard is simply skipped.
|
|
231
|
+
"""
|
|
232
|
+
if not getattr(spec, "mcp", None):
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
from agentship_langgraph.mcp import mcp_version_ok
|
|
236
|
+
except ImportError:
|
|
237
|
+
return None
|
|
238
|
+
ok, installed = mcp_version_ok()
|
|
239
|
+
if ok:
|
|
240
|
+
return None
|
|
241
|
+
if installed is None:
|
|
242
|
+
return "this agent declares mcp: servers — pip install 'agentship-langgraph[mcp]'"
|
|
243
|
+
return (
|
|
244
|
+
f"mcp {installed} is installed but agentship needs mcp>=1.28,<2 "
|
|
245
|
+
f"(v2 reshaped the client API) — pip install 'mcp>=1.28,<2'"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _check_autonomous_version(spec) -> str | None:
|
|
250
|
+
"""Guard a ``template: autonomous`` spec against a missing/drifted deepagents install.
|
|
251
|
+
|
|
252
|
+
The ``autonomous`` template wraps the deepagents library, which is pre-1.0 (its
|
|
253
|
+
``create_deep_agent`` signature can drift), so an ``autonomous`` spec is only
|
|
254
|
+
healthy when the pinned version is installed. This reads the langgraph adapter's
|
|
255
|
+
version guard *if that adapter is importable* (``doctor`` runs in projects that
|
|
256
|
+
may not have it), returning an actionable reason when deepagents is absent or the
|
|
257
|
+
wrong version, or ``None`` when the spec is not an autonomous one or the install
|
|
258
|
+
is fine. Never raises: an import failure just means the guard is skipped (the
|
|
259
|
+
capability gate already vouched for the engine).
|
|
260
|
+
"""
|
|
261
|
+
if getattr(spec, "template", None) != "autonomous":
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
from agentship_langgraph.templates.autonomous_tpl import (
|
|
265
|
+
PINNED_DEEPAGENTS_VERSION,
|
|
266
|
+
deepagents_version_ok,
|
|
267
|
+
)
|
|
268
|
+
except ImportError:
|
|
269
|
+
return None # langgraph adapter not importable here — skip the extra guard
|
|
270
|
+
ok, installed = deepagents_version_ok()
|
|
271
|
+
if ok:
|
|
272
|
+
return None
|
|
273
|
+
if installed is None:
|
|
274
|
+
return (
|
|
275
|
+
"template 'autonomous' needs the deepagents package — "
|
|
276
|
+
"pip install 'agentship-langgraph[autonomous]'"
|
|
277
|
+
)
|
|
278
|
+
return (
|
|
279
|
+
f"template 'autonomous' is pinned to deepagents=={PINNED_DEEPAGENTS_VERSION} "
|
|
280
|
+
f"but {installed} is installed — pip install "
|
|
281
|
+
f"'deepagents=={PINNED_DEEPAGENTS_VERSION}' (pre-1.0 API can drift)"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _agent_files(agents_dir: Path) -> list[Path]:
|
|
286
|
+
"""Return the sorted ``*.yaml``/``*.yml`` files directly under ``agents_dir``.
|
|
287
|
+
|
|
288
|
+
Only the directory's own specs are listed (not a deep walk), so a nested
|
|
289
|
+
Python package or fixtures folder is never mistaken for an agent spec.
|
|
290
|
+
"""
|
|
291
|
+
files = sorted(p for p in agents_dir.iterdir() if p.suffix in (".yaml", ".yml"))
|
|
292
|
+
return files
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@main.command()
|
|
296
|
+
@click.argument("target", type=click.Path(exists=True), required=False)
|
|
297
|
+
@click.option(
|
|
298
|
+
"--agents-dir",
|
|
299
|
+
"agents_dir",
|
|
300
|
+
type=click.Path(exists=True, file_okay=False),
|
|
301
|
+
default=None,
|
|
302
|
+
help="Validate every *.yaml agent in this directory (default: ./agents).",
|
|
303
|
+
)
|
|
304
|
+
@click.option(
|
|
305
|
+
"--debug", is_flag=True, help="Re-raise on an unexpected failure for the full traceback."
|
|
306
|
+
)
|
|
307
|
+
def doctor(target: str | None, agents_dir: str | None, debug: bool) -> None:
|
|
308
|
+
"""Validate agent specs against their engines' declared capabilities.
|
|
309
|
+
|
|
310
|
+
Give either a single spec FILE or ``--agents-dir DIR`` (default ``./agents``).
|
|
311
|
+
For each agent this loads the YAML, resolves its ``engine`` from the registry,
|
|
312
|
+
and runs the capability gate. It prints a per-agent status line — ``OK`` or
|
|
313
|
+
``✗`` with the reason — and exits ``1`` if *any* agent is invalid, ``0`` when
|
|
314
|
+
all pass.
|
|
315
|
+
|
|
316
|
+
Every expected failure is a clean status/``Error:`` line, never a traceback:
|
|
317
|
+
a bad YAML, an unknown field, an engine whose package is not installed (the
|
|
318
|
+
reason names the exact ``pip install`` fix), or a capability mismatch. Pass
|
|
319
|
+
``--debug`` to re-raise an *unexpected* error with its full traceback.
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
files = _resolve_doctor_targets(target, agents_dir)
|
|
323
|
+
except AgentShipError as exc:
|
|
324
|
+
click.echo(f"Error: {exc}", err=True)
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
|
|
327
|
+
any_bad = False
|
|
328
|
+
for path in files:
|
|
329
|
+
try:
|
|
330
|
+
reason = _check_agent(path)
|
|
331
|
+
except AgentShipError as exc:
|
|
332
|
+
# Expected harness failure (bad spec / capability mismatch): show it as
|
|
333
|
+
# this agent's reason, keep checking the rest.
|
|
334
|
+
reason = str(exc)
|
|
335
|
+
except Exception as exc: # noqa: BLE001 - unexpected; surface cleanly or re-raise
|
|
336
|
+
if debug:
|
|
337
|
+
raise
|
|
338
|
+
reason = f"{exc} (run with --debug for the full traceback)"
|
|
339
|
+
if reason is None:
|
|
340
|
+
click.echo(f"OK {path.name}")
|
|
341
|
+
else:
|
|
342
|
+
any_bad = True
|
|
343
|
+
click.echo(f"✗ {path.name}: {reason}")
|
|
344
|
+
|
|
345
|
+
if any_bad:
|
|
346
|
+
sys.exit(1)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _resolve_doctor_targets(target: str | None, agents_dir: str | None) -> list[Path]:
|
|
350
|
+
"""Resolve the doctor command's inputs to a non-empty list of spec files.
|
|
351
|
+
|
|
352
|
+
Precedence: an explicit ``target`` file/dir wins; otherwise ``--agents-dir``;
|
|
353
|
+
otherwise the default ``./agents`` directory. Raises
|
|
354
|
+
:class:`~agentship.errors.AgentShipError` with an actionable message when the
|
|
355
|
+
default ``./agents`` is missing or when a chosen directory holds no specs, so
|
|
356
|
+
the caller prints one clean ``Error:`` line rather than silently passing.
|
|
357
|
+
"""
|
|
358
|
+
if target is not None:
|
|
359
|
+
path = Path(target)
|
|
360
|
+
if path.is_dir():
|
|
361
|
+
return _require_specs(path)
|
|
362
|
+
return [path]
|
|
363
|
+
if agents_dir is not None:
|
|
364
|
+
return _require_specs(Path(agents_dir))
|
|
365
|
+
default = Path("agents")
|
|
366
|
+
if not default.is_dir():
|
|
367
|
+
raise AgentShipError(
|
|
368
|
+
"no agents to check — pass a spec FILE, use --agents-dir DIR, or run "
|
|
369
|
+
"from a project with an ./agents directory (see `agentship init`)"
|
|
370
|
+
)
|
|
371
|
+
return _require_specs(default)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _require_specs(agents_dir: Path) -> list[Path]:
|
|
375
|
+
"""Return the specs under ``agents_dir`` or raise when there are none."""
|
|
376
|
+
files = _agent_files(agents_dir)
|
|
377
|
+
if not files:
|
|
378
|
+
raise AgentShipError(f"no *.yaml agent specs found in {agents_dir}")
|
|
379
|
+
return files
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _write_new_file(path: Path, text: str, *, force: bool = False) -> None:
|
|
383
|
+
"""Write ``text`` to ``path``, refusing to overwrite unless ``force`` is set.
|
|
384
|
+
|
|
385
|
+
Scaffolding never clobbers a user's work by default: an existing target raises
|
|
386
|
+
:class:`~agentship.errors.AgentShipError` so the caller reports a clean error
|
|
387
|
+
instead of silently replacing content. Passing ``force=True`` (from
|
|
388
|
+
``--force``) allows the overwrite — for re-scaffolding an agent on purpose.
|
|
389
|
+
Parent directories are created first.
|
|
390
|
+
"""
|
|
391
|
+
if path.exists() and not force:
|
|
392
|
+
raise AgentShipError(
|
|
393
|
+
f"refusing to overwrite existing file: {path} (pass --force to replace it)"
|
|
394
|
+
)
|
|
395
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
path.write_text(text)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
#: The agent-name pattern the spec's registry key must match: a lowercase
|
|
400
|
+
#: identifier (letter first, then letters/digits/underscore). Validated by
|
|
401
|
+
#: ``new-agent`` *before* any file is written so a bad name fails fast and cleanly
|
|
402
|
+
#: rather than producing a spec that later fails to load or a broken Python
|
|
403
|
+
#: class name in a ``graph`` scaffold.
|
|
404
|
+
_AGENT_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _validate_agent_name(name: str) -> None:
|
|
408
|
+
"""Raise :class:`AgentShipError` if ``name`` is not a valid agent identifier.
|
|
409
|
+
|
|
410
|
+
Keeps scaffolding total: the name becomes the spec's ``name``, the YAML file
|
|
411
|
+
stem, and (for ``graph``) a Python class prefix, so it must be a plain
|
|
412
|
+
lowercase identifier. An invalid name is refused before any file is touched.
|
|
413
|
+
"""
|
|
414
|
+
if not _AGENT_NAME_PATTERN.match(name):
|
|
415
|
+
raise AgentShipError(
|
|
416
|
+
f"invalid agent name {name!r} — use lowercase letters, digits and "
|
|
417
|
+
f"underscores, starting with a letter (e.g. 'ticket_router')"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@main.command()
|
|
422
|
+
@click.argument("directory", type=click.Path(file_okay=False), default=".")
|
|
423
|
+
def init(directory: str) -> None:
|
|
424
|
+
"""Scaffold a new single-tenant AgentShip project in DIRECTORY (default ``.``).
|
|
425
|
+
|
|
426
|
+
Creates an ``agents/`` folder with a starter ``assistant.yaml`` (the default
|
|
427
|
+
``langgraph`` engine over ``openai/gpt-4o-mini``), a ``.env.example`` naming the
|
|
428
|
+
one key the quickstart needs, and a ``README.md`` showing the ``agentship run``
|
|
429
|
+
path. The scaffold is single-tenant — no auth or tenancy concepts — so a fresh
|
|
430
|
+
project just runs.
|
|
431
|
+
|
|
432
|
+
Existing files are never overwritten: if any target already exists the command
|
|
433
|
+
reports a clean ``Error:`` and exits ``1`` without touching your files.
|
|
434
|
+
"""
|
|
435
|
+
root = Path(directory)
|
|
436
|
+
try:
|
|
437
|
+
_write_new_file(root / "agents" / "assistant.yaml", scaffold.ASSISTANT_YAML)
|
|
438
|
+
_write_new_file(root / ".env.example", scaffold.ENV_EXAMPLE)
|
|
439
|
+
_write_new_file(root / "README.md", scaffold.README)
|
|
440
|
+
except AgentShipError as exc:
|
|
441
|
+
click.echo(f"Error: {exc}", err=True)
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
|
|
444
|
+
click.echo(f"Scaffolded an AgentShip project in {root}/")
|
|
445
|
+
click.echo("Next: cp .env.example .env # set OPENAI_API_KEY")
|
|
446
|
+
click.echo(' agentship run agents/assistant.yaml --input "hello"')
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
#: The templates ``new-agent`` can scaffold. ``single`` and ``autonomous`` are
|
|
450
|
+
#: pure-YAML (the langgraph engine ships their build body); ``graph`` is a
|
|
451
|
+
#: custom-authoring scaffold that also writes a companion ``agent.py``.
|
|
452
|
+
_TEMPLATE_CHOICES = ("single", "graph", "autonomous")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@main.command(name="new-agent")
|
|
456
|
+
@click.argument("name")
|
|
457
|
+
@click.option(
|
|
458
|
+
"--template",
|
|
459
|
+
type=click.Choice(_TEMPLATE_CHOICES),
|
|
460
|
+
default="single",
|
|
461
|
+
show_default=True,
|
|
462
|
+
help="Which agent template to scaffold.",
|
|
463
|
+
)
|
|
464
|
+
@click.option(
|
|
465
|
+
"--agents-dir",
|
|
466
|
+
"agents_dir",
|
|
467
|
+
type=click.Path(file_okay=False),
|
|
468
|
+
default="agents",
|
|
469
|
+
show_default=True,
|
|
470
|
+
help="Directory to write the agent spec into.",
|
|
471
|
+
)
|
|
472
|
+
@click.option(
|
|
473
|
+
"--force",
|
|
474
|
+
is_flag=True,
|
|
475
|
+
help="Overwrite existing scaffold files instead of refusing.",
|
|
476
|
+
)
|
|
477
|
+
def new_agent(name: str, template: str, agents_dir: str, force: bool) -> None:
|
|
478
|
+
"""Scaffold one starter agent from a template at ``<agents-dir>/NAME.yaml``.
|
|
479
|
+
|
|
480
|
+
``--template single`` (default) and ``--template autonomous`` write a single
|
|
481
|
+
pure-YAML spec — no companion Python — that the ``langgraph`` engine turns into
|
|
482
|
+
a runnable agent from the YAML alone. ``--template graph`` writes both
|
|
483
|
+
``NAME.yaml`` and a companion ``NAME/agent.py``: a fillable
|
|
484
|
+
:class:`~agentship_langgraph.agent.LangGraphAgent` supervisor scaffold whose
|
|
485
|
+
``build_graph`` carries ``# TODO(author)`` markers, with the YAML's ``code:``
|
|
486
|
+
pointing at it (written as an absolute path so the spec builds regardless of the
|
|
487
|
+
working directory it is loaded from).
|
|
488
|
+
|
|
489
|
+
``NAME`` is validated against the agent-name pattern before any file is written,
|
|
490
|
+
so a bad name fails fast and cleanly. Existing files are never clobbered: the
|
|
491
|
+
command reports a clean ``Error:`` and exits ``1`` unless ``--force`` is passed.
|
|
492
|
+
"""
|
|
493
|
+
try:
|
|
494
|
+
_validate_agent_name(name)
|
|
495
|
+
written = _scaffold_agent(name, template, Path(agents_dir), force=force)
|
|
496
|
+
except AgentShipError as exc:
|
|
497
|
+
click.echo(f"Error: {exc}", err=True)
|
|
498
|
+
sys.exit(1)
|
|
499
|
+
for path in written:
|
|
500
|
+
click.echo(f"Wrote {path}")
|
|
501
|
+
yaml_path = Path(agents_dir) / f"{name}.yaml"
|
|
502
|
+
click.echo(f'Run it: agentship run {yaml_path} --input "hello"')
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _scaffold_agent(name: str, template: str, agents_dir: Path, *, force: bool) -> list[Path]:
|
|
506
|
+
"""Write the files for ``template`` and return the paths written (YAML first).
|
|
507
|
+
|
|
508
|
+
Dispatches on ``template``: ``single``/``autonomous`` write one pure-YAML spec;
|
|
509
|
+
``graph`` writes ``NAME.yaml`` plus a companion ``NAME/agent.py`` supervisor
|
|
510
|
+
scaffold, with the YAML's ``code:`` referencing the ``agent.py`` by its absolute
|
|
511
|
+
path so :func:`agentship.spec.resolve_code` finds it from any working directory.
|
|
512
|
+
Refuses to overwrite an existing file unless ``force`` is set (via
|
|
513
|
+
:func:`_write_new_file`). An unknown ``template`` is impossible here — Click's
|
|
514
|
+
``Choice`` rejects it at parse time — but is still guarded so a future caller
|
|
515
|
+
gets a clean error rather than a silent miss.
|
|
516
|
+
"""
|
|
517
|
+
yaml_path = agents_dir / f"{name}.yaml"
|
|
518
|
+
if template == "single":
|
|
519
|
+
_write_new_file(yaml_path, scaffold.single_template_yaml(name), force=force)
|
|
520
|
+
return [yaml_path]
|
|
521
|
+
if template == "autonomous":
|
|
522
|
+
_write_new_file(yaml_path, scaffold.autonomous_template_yaml(name), force=force)
|
|
523
|
+
return [yaml_path]
|
|
524
|
+
if template == "graph":
|
|
525
|
+
agent_py = (agents_dir / name / "agent.py").resolve()
|
|
526
|
+
code_ref = f"{agent_py}:build_agent"
|
|
527
|
+
# Write the companion agent.py first so a mid-way failure never leaves a
|
|
528
|
+
# YAML whose code: points at a missing file.
|
|
529
|
+
_write_new_file(agent_py, scaffold.graph_template_agent_py(name), force=force)
|
|
530
|
+
_write_new_file(yaml_path, scaffold.graph_template_yaml(name, code_ref), force=force)
|
|
531
|
+
return [yaml_path, agent_py]
|
|
532
|
+
raise AgentShipError(f"unknown template {template!r} — choose one of {_TEMPLATE_CHOICES}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@main.command()
|
|
536
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Interface to bind.")
|
|
537
|
+
@click.option("--port", default=8000, show_default=True, type=int, help="Port to bind.")
|
|
538
|
+
@click.option(
|
|
539
|
+
"--reload",
|
|
540
|
+
is_flag=True,
|
|
541
|
+
help="Auto-reload on code changes (dev; mutually exclusive with --workers>1).",
|
|
542
|
+
)
|
|
543
|
+
@click.option(
|
|
544
|
+
"--workers",
|
|
545
|
+
type=int,
|
|
546
|
+
default=None,
|
|
547
|
+
help="Number of worker processes (prod; mutually exclusive with --reload).",
|
|
548
|
+
)
|
|
549
|
+
@click.option(
|
|
550
|
+
"--agents-dir",
|
|
551
|
+
"agents_dir",
|
|
552
|
+
type=click.Path(file_okay=False),
|
|
553
|
+
default="agents",
|
|
554
|
+
show_default=True,
|
|
555
|
+
help="Directory of agent specs to serve.",
|
|
556
|
+
)
|
|
557
|
+
@click.option(
|
|
558
|
+
"--auth",
|
|
559
|
+
"auth_provider",
|
|
560
|
+
default="api_key",
|
|
561
|
+
show_default=True,
|
|
562
|
+
help="Auth provider name (api_key | forwarded | jwt | composite).",
|
|
563
|
+
)
|
|
564
|
+
@click.option(
|
|
565
|
+
"--env-file",
|
|
566
|
+
default=None,
|
|
567
|
+
help="Load provider credentials from this .env before serving (default: ./.env).",
|
|
568
|
+
)
|
|
569
|
+
@click.option(
|
|
570
|
+
"--log-level",
|
|
571
|
+
"log_level",
|
|
572
|
+
default=lambda: os.environ.get("AGENTSHIP_LOG_LEVEL", "info"),
|
|
573
|
+
show_default="info (or $AGENTSHIP_LOG_LEVEL)",
|
|
574
|
+
help="Log level for the agentship.* loggers: debug | info | warning | error.",
|
|
575
|
+
)
|
|
576
|
+
def serve(
|
|
577
|
+
host: str,
|
|
578
|
+
port: int,
|
|
579
|
+
reload: bool,
|
|
580
|
+
workers: int | None,
|
|
581
|
+
agents_dir: str,
|
|
582
|
+
auth_provider: str,
|
|
583
|
+
env_file: str | None,
|
|
584
|
+
log_level: str,
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Serve the agents in AGENTS-DIR over the secure ``/v1`` REST/SSE/WS surface.
|
|
587
|
+
|
|
588
|
+
This is the supported launch path for the runtime service. It is **doctor-gated**: every
|
|
589
|
+
``agents/*.yaml`` is validated and the auth provider is built *before* the socket binds,
|
|
590
|
+
so an invalid spec, an uninstalled engine, or an uninstalled/misconfigured auth provider
|
|
591
|
+
fails fast (exit ``1``) rather than after the server is already listening. The app is
|
|
592
|
+
then built by the same ``create_app()`` factory tests use and run under uvicorn.
|
|
593
|
+
|
|
594
|
+
``--host`` defaults to loopback (``127.0.0.1``) so a bare ``agentship serve`` is not
|
|
595
|
+
network-exposed. ``--reload`` (dev) and ``--workers>1`` (prod) are mutually exclusive —
|
|
596
|
+
a uvicorn constraint — and passing both is a usage error (exit ``2``).
|
|
597
|
+
"""
|
|
598
|
+
if reload and workers and workers > 1:
|
|
599
|
+
raise click.UsageError(
|
|
600
|
+
"--reload and --workers>1 are mutually exclusive (uvicorn constraint)"
|
|
601
|
+
)
|
|
602
|
+
try:
|
|
603
|
+
_serve(host, port, reload, workers, Path(agents_dir), auth_provider, env_file, log_level)
|
|
604
|
+
except AgentShipError as exc:
|
|
605
|
+
# Doctor-gate failure (bad spec / uninstalled or misconfigured provider): exit 1
|
|
606
|
+
# before anything is bound.
|
|
607
|
+
click.echo(f"Error: {exc}", err=True)
|
|
608
|
+
sys.exit(1)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _serve(
|
|
612
|
+
host: str,
|
|
613
|
+
port: int,
|
|
614
|
+
reload: bool,
|
|
615
|
+
workers: int | None,
|
|
616
|
+
agents_dir: Path,
|
|
617
|
+
auth_provider: str,
|
|
618
|
+
env_file: str | None,
|
|
619
|
+
log_level: str = "info",
|
|
620
|
+
) -> None:
|
|
621
|
+
"""Doctor-gate, configure the app factory's environment, and launch the server.
|
|
622
|
+
|
|
623
|
+
Raises :class:`~agentship.errors.AgentShipError` (→ the command exits 1) if any spec is
|
|
624
|
+
invalid or the auth provider cannot be built; only past that gate does it hand off to
|
|
625
|
+
:func:`run_server`, which binds the socket.
|
|
626
|
+
"""
|
|
627
|
+
from agentship.auth.registry import build_auth_provider
|
|
628
|
+
from agentship_service.serving import (
|
|
629
|
+
ENV_AGENTS_DIR,
|
|
630
|
+
ENV_AUTH_PROVIDER,
|
|
631
|
+
_auth_config_from_env,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
load_env_for_run(env_file)
|
|
635
|
+
|
|
636
|
+
# Doctor-gate: validate every spec before binding, with actionable per-agent reasons.
|
|
637
|
+
specs = _agent_files(agents_dir) if agents_dir.is_dir() else []
|
|
638
|
+
failures = [(path.name, _check_agent(path)) for path in specs]
|
|
639
|
+
failures = [(name, reason) for name, reason in failures if reason is not None]
|
|
640
|
+
if failures:
|
|
641
|
+
for name, reason in failures:
|
|
642
|
+
click.echo(f"✗ {name}: {reason}", err=True)
|
|
643
|
+
raise AgentShipError(
|
|
644
|
+
f"doctor gate failed: {len(failures)} invalid agent spec(s) — fix before serving"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Configure the factory's environment, then build the provider once to fail fast on an
|
|
648
|
+
# uninstalled or misconfigured provider (e.g. forwarded-header without an allow-list).
|
|
649
|
+
os.environ[ENV_AGENTS_DIR] = str(agents_dir)
|
|
650
|
+
os.environ[ENV_AUTH_PROVIDER] = auth_provider
|
|
651
|
+
build_auth_provider(auth_provider, _auth_config_from_env(auth_provider))
|
|
652
|
+
|
|
653
|
+
# Turn the agentship.* logger tree on. Without this every component logger
|
|
654
|
+
# (engine, supervisor, tools, mcp, skills) is silent, so a served deployment shows
|
|
655
|
+
# only uvicorn's access lines and nothing about what an agent actually did.
|
|
656
|
+
configure_logging(log_level)
|
|
657
|
+
|
|
658
|
+
from agentship_service.build_info import build_id, installed_versions
|
|
659
|
+
|
|
660
|
+
versions = installed_versions()
|
|
661
|
+
click.echo(f"Serving {len(specs)} agent(s) from {agents_dir} on http://{host}:{port}")
|
|
662
|
+
click.echo(f"Auth provider: {auth_provider} Log level: {log_level}")
|
|
663
|
+
click.echo(
|
|
664
|
+
f"Build: {build_id()} "
|
|
665
|
+
+ " ".join(f"{n.removeprefix('agentship-')}={v}" for n, v in versions.items())
|
|
666
|
+
)
|
|
667
|
+
run_server(host=host, port=port, reload=reload, workers=workers)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class _QuietHealthChecks(logging.Filter):
|
|
671
|
+
"""Drop uvicorn access lines for ``/healthz`` so the probe does not drown the log.
|
|
672
|
+
|
|
673
|
+
Docker and Railway both poll the health endpoint every few seconds. Left alone that is
|
|
674
|
+
one access line per probe forever, which buries the requests a reader actually cares
|
|
675
|
+
about. The probe still shows up at DEBUG via uvicorn itself; only the access line goes.
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
679
|
+
"""Return False (drop) when the access record is a health-check request."""
|
|
680
|
+
return "/healthz" not in record.getMessage()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def run_server(*, host: str, port: int, reload: bool, workers: int | None) -> None:
|
|
684
|
+
"""Launch uvicorn against the env-configured app factory (indirected for testability).
|
|
685
|
+
|
|
686
|
+
Uses the import-string factory ``agentship_service.serving:build_from_env`` so uvicorn
|
|
687
|
+
can rebuild the app in ``--reload``/``--workers`` subprocesses from the environment the
|
|
688
|
+
doctor-gate already configured. Tests monkeypatch this function to avoid binding a socket.
|
|
689
|
+
"""
|
|
690
|
+
import uvicorn
|
|
691
|
+
|
|
692
|
+
logging.getLogger("uvicorn.access").addFilter(_QuietHealthChecks())
|
|
693
|
+
|
|
694
|
+
uvicorn.run(
|
|
695
|
+
"agentship_service.serving:build_from_env",
|
|
696
|
+
factory=True,
|
|
697
|
+
host=host,
|
|
698
|
+
port=port,
|
|
699
|
+
reload=reload,
|
|
700
|
+
workers=workers or None,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
#: Hosts treated as loopback — the only interfaces ``agentship studio`` may bind (§13.8).
|
|
705
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@main.command()
|
|
709
|
+
@click.option(
|
|
710
|
+
"--engine",
|
|
711
|
+
type=click.Choice(["langgraph", "adk"]),
|
|
712
|
+
default="langgraph",
|
|
713
|
+
show_default=True,
|
|
714
|
+
help="Which consumed studio UI to open (LangGraph Studio or ADK web).",
|
|
715
|
+
)
|
|
716
|
+
@click.option(
|
|
717
|
+
"--host",
|
|
718
|
+
default="127.0.0.1",
|
|
719
|
+
show_default=True,
|
|
720
|
+
help="Interface to bind — loopback only (127.0.0.1 | localhost | ::1).",
|
|
721
|
+
)
|
|
722
|
+
@click.option("--port", default=2024, show_default=True, type=int, help="Studio dev-server port.")
|
|
723
|
+
@click.option(
|
|
724
|
+
"--agents-dir",
|
|
725
|
+
"agents_dir",
|
|
726
|
+
type=click.Path(file_okay=False),
|
|
727
|
+
default="agents",
|
|
728
|
+
show_default=True,
|
|
729
|
+
help="Directory of agent specs to open in the studio.",
|
|
730
|
+
)
|
|
731
|
+
@click.option(
|
|
732
|
+
"--env-file",
|
|
733
|
+
default=None,
|
|
734
|
+
help="Load credentials from this .env before launching (default: ./.env).",
|
|
735
|
+
)
|
|
736
|
+
def studio(engine: str, host: str, port: int, agents_dir: str, env_file: str | None) -> None:
|
|
737
|
+
"""Open a consumed studio UI (LangGraph Studio / ADK web) against AGENTS-DIR.
|
|
738
|
+
|
|
739
|
+
We do **not** build a studio: for ``--engine langgraph`` we generate a ``langgraph.json``
|
|
740
|
+
manifest from the discovered LangGraph agents and shell to ``langgraph dev``; for
|
|
741
|
+
``--engine adk`` we shell to ADK web. Phoenix remains the trace/eval surface.
|
|
742
|
+
|
|
743
|
+
**Loopback-only (§13.8, non-negotiable):** the studio binds ``127.0.0.1`` with a minted dev
|
|
744
|
+
token and a ``dev`` principal, and **refuses any non-loopback host outright**. Studio
|
|
745
|
+
time-travel reads checkpoints, so exposing it on a network interface without an ``AuthProvider``
|
|
746
|
+
is a hard cross-tenant leak — expose it only behind the secure service.
|
|
747
|
+
"""
|
|
748
|
+
try:
|
|
749
|
+
_studio(engine, host, port, Path(agents_dir), env_file)
|
|
750
|
+
except AgentShipError as exc:
|
|
751
|
+
click.echo(f"Error: {exc}", err=True)
|
|
752
|
+
sys.exit(1)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _require_loopback(host: str) -> None:
|
|
756
|
+
"""Raise unless ``host`` is a loopback interface — the studio's hard §13.8 guard."""
|
|
757
|
+
if host not in LOOPBACK_HOSTS:
|
|
758
|
+
raise AgentShipError(
|
|
759
|
+
f"studio binds loopback only (§13.8); refusing host {host!r}. Studio time-travel reads "
|
|
760
|
+
"checkpoints — put it behind an AuthProvider to expose it on a network interface."
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _studio(engine: str, host: str, port: int, agents_dir: Path, env_file: str | None) -> None:
|
|
765
|
+
"""Enforce the loopback guard, mint a dev token, generate the manifest, and launch the UI.
|
|
766
|
+
|
|
767
|
+
Raises :class:`~agentship.errors.AgentShipError` (→ exit 1) on a non-loopback host or when no
|
|
768
|
+
renderable agent is found; only past those gates does it hand off to :func:`run_studio_process`,
|
|
769
|
+
which execs the consumed dev server.
|
|
770
|
+
"""
|
|
771
|
+
import secrets
|
|
772
|
+
|
|
773
|
+
_require_loopback(host)
|
|
774
|
+
load_env_for_run(env_file)
|
|
775
|
+
|
|
776
|
+
# Mint a dev token → dev principal for this loopback session (reuse one already in the env).
|
|
777
|
+
dev_token = os.environ.get("AGENTSHIP_DEV_TOKEN") or secrets.token_urlsafe(24)
|
|
778
|
+
env = {**os.environ, "AGENTSHIP_DEV_TOKEN": dev_token, "AGENTSHIP_PRINCIPAL": "dev"}
|
|
779
|
+
|
|
780
|
+
if engine == "langgraph":
|
|
781
|
+
from agentship.observability.studio import generate_langgraph_json
|
|
782
|
+
|
|
783
|
+
manifest = generate_langgraph_json(agents_dir, Path("langgraph.json").resolve())
|
|
784
|
+
click.echo(f"Wrote {manifest}")
|
|
785
|
+
cmd = ["langgraph", "dev", "--host", host, "--port", str(port)]
|
|
786
|
+
cwd = manifest.parent
|
|
787
|
+
else:
|
|
788
|
+
cmd = ["adk", "web", "--host", host, "--port", str(port), str(agents_dir.resolve())]
|
|
789
|
+
cwd = agents_dir.parent
|
|
790
|
+
|
|
791
|
+
click.echo(f"Dev token (loopback only): {dev_token}")
|
|
792
|
+
click.echo(f"Opening {engine} studio on http://{host}:{port}")
|
|
793
|
+
run_studio_process(cmd, env, cwd=cwd)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def run_studio_process(cmd: list[str], env: dict[str, str], *, cwd: Path) -> None:
|
|
797
|
+
"""Exec the consumed studio dev server (indirected so tests can avoid launching it).
|
|
798
|
+
|
|
799
|
+
Runs ``langgraph dev`` / ``adk web`` in ``cwd`` with the dev-token environment. Tests
|
|
800
|
+
monkeypatch this to assert the command and loopback host without spawning a process.
|
|
801
|
+
"""
|
|
802
|
+
import subprocess
|
|
803
|
+
|
|
804
|
+
subprocess.run(cmd, env=env, cwd=str(cwd), check=True)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
@main.command()
|
|
808
|
+
@click.option(
|
|
809
|
+
"--agents-dir",
|
|
810
|
+
"agents_dir",
|
|
811
|
+
type=click.Path(exists=True, file_okay=False),
|
|
812
|
+
default=None,
|
|
813
|
+
help="Also verify the agents + wired contracts of this project directory.",
|
|
814
|
+
)
|
|
815
|
+
@click.option(
|
|
816
|
+
"--live/--offline",
|
|
817
|
+
"live",
|
|
818
|
+
default=False,
|
|
819
|
+
show_default=True,
|
|
820
|
+
help="Reserved for live-provider checks; today every section runs fully offline.",
|
|
821
|
+
)
|
|
822
|
+
@click.option(
|
|
823
|
+
"--debug", is_flag=True, help="Re-raise on an unexpected failure for the full traceback."
|
|
824
|
+
)
|
|
825
|
+
def verify(agents_dir: str | None, live: bool, debug: bool) -> None:
|
|
826
|
+
"""Prove that installed engines honour their declared capabilities — no over-claims.
|
|
827
|
+
|
|
828
|
+
This is the user-facing surface for AgentShip's "verifiable agents" theme. It runs
|
|
829
|
+
a themed report of sections and exits non-zero if *any* present section has a real
|
|
830
|
+
failure (an over-claim, an invalid spec, a broken contract); a section whose
|
|
831
|
+
optional dependency is missing, or which has nothing to check, is reported
|
|
832
|
+
``SKIPPED`` with a reason and never fails the run (declare, don't fake):
|
|
833
|
+
|
|
834
|
+
- ``engine×capability grid`` — every installed engine really honours what it
|
|
835
|
+
declares (and rejects what it does not); any failed ``prove`` cell is named as
|
|
836
|
+
an over-claim.
|
|
837
|
+
- ``spec validation`` — with ``--agents-dir``, every spec loads and passes the same
|
|
838
|
+
capability gate ``doctor`` runs.
|
|
839
|
+
- ``observability span-tree`` — one ``echo`` run emits the frozen root ``agent``
|
|
840
|
+
span (skipped if the observability extra is absent).
|
|
841
|
+
- ``service contracts`` — with ``--agents-dir``, the served app rejects
|
|
842
|
+
unauthenticated calls and isolates tenants (skipped if the service extra is
|
|
843
|
+
absent).
|
|
844
|
+
- ``A2A interop`` — every ``a2a.expose`` agent renders a schema-valid, honest Agent
|
|
845
|
+
Card (skipped if none is exposed).
|
|
846
|
+
|
|
847
|
+
Runs fully offline (fake model seam + the model-free ``echo`` engine), so it needs
|
|
848
|
+
no provider keys. ``--debug`` re-raises an *unexpected* error with its traceback.
|
|
849
|
+
"""
|
|
850
|
+
from .verify import run_verification
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
report = asyncio.run(run_verification(Path(agents_dir) if agents_dir else None, live=live))
|
|
854
|
+
except AgentShipError as exc:
|
|
855
|
+
click.echo(f"Error: {exc}", err=True)
|
|
856
|
+
sys.exit(1)
|
|
857
|
+
except Exception as exc: # noqa: BLE001 - unexpected; surface cleanly or re-raise
|
|
858
|
+
if debug:
|
|
859
|
+
raise
|
|
860
|
+
click.echo(f"Error: {exc} (run with --debug for the full traceback)", err=True)
|
|
861
|
+
sys.exit(1)
|
|
862
|
+
|
|
863
|
+
_print_verify_report(report)
|
|
864
|
+
if not report.ok:
|
|
865
|
+
sys.exit(1)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
#: Width the section label is dotted-out to, so the status column lines up in the report.
|
|
869
|
+
_VERIFY_LABEL_WIDTH = 28
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _print_verify_report(report) -> None:
|
|
873
|
+
"""Render a :class:`~agentship_cli.verify.VerifyReport` as the themed status block.
|
|
874
|
+
|
|
875
|
+
Each section prints ``label ...... passed/total <status>`` — a green check when it
|
|
876
|
+
ran and passed, a red cross when it really failed, or ``SKIPPED (reason)`` when it
|
|
877
|
+
declared a missing path rather than faking one. Failure details (each named
|
|
878
|
+
over-claim / invalid spec / broken contract) are indented beneath their section.
|
|
879
|
+
The closing arrow summarises the headline honesty claim.
|
|
880
|
+
"""
|
|
881
|
+
for section in report.sections:
|
|
882
|
+
label = section.name
|
|
883
|
+
dots = "." * max(3, _VERIFY_LABEL_WIDTH - len(label))
|
|
884
|
+
if section.skipped:
|
|
885
|
+
click.echo(f" {label} {dots} SKIPPED ({section.skipped_reason})")
|
|
886
|
+
continue
|
|
887
|
+
mark = "✅" if not section.failed else "❌"
|
|
888
|
+
click.echo(f" {label} {dots} {section.passed}/{section.total} {mark}")
|
|
889
|
+
if section.failed:
|
|
890
|
+
for detail in section.details:
|
|
891
|
+
click.echo(f" - {detail}")
|
|
892
|
+
|
|
893
|
+
if report.ok:
|
|
894
|
+
click.echo(f" → all declared capabilities proven, {len(report.over_claims)} over-claims")
|
|
895
|
+
else:
|
|
896
|
+
n = len(report.over_claims)
|
|
897
|
+
click.echo(f" → verification FAILED — {n} over-claim(s); see failures above")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@main.group()
|
|
901
|
+
def db() -> None:
|
|
902
|
+
"""Database schema commands (the gated owner of all DDL)."""
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _pending_migrations() -> list[Migration]:
|
|
906
|
+
"""Return the registered migrations sorted by ``version`` (apply order).
|
|
907
|
+
|
|
908
|
+
This is the full plan for a fresh database. Because every migration is
|
|
909
|
+
idempotent, applying already-present ones is a safe no-op, so the runner
|
|
910
|
+
does not need to track applied state to be correct today. Later phases that
|
|
911
|
+
add real DDL may add a version ledger; the KISS Week-1 runner does not.
|
|
912
|
+
"""
|
|
913
|
+
return sorted(REGISTERED_MIGRATIONS, key=lambda m: m.version)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _resolve_database_url(database_url: str | None) -> str | None:
|
|
917
|
+
"""Resolve the database DSN from the flag, then the environment.
|
|
918
|
+
|
|
919
|
+
Precedence: an explicit ``--database-url`` wins; otherwise the first set of
|
|
920
|
+
:data:`DATABASE_URL_ENV_VARS`. Returns ``None`` when no DSN is available so
|
|
921
|
+
the caller can decide whether that is fatal (it is only fatal when there are
|
|
922
|
+
pending migrations to apply).
|
|
923
|
+
"""
|
|
924
|
+
if database_url:
|
|
925
|
+
return database_url
|
|
926
|
+
for name in DATABASE_URL_ENV_VARS:
|
|
927
|
+
value = os.environ.get(name)
|
|
928
|
+
if value:
|
|
929
|
+
return value
|
|
930
|
+
return None
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _print_plan(migrations: list[Migration]) -> None:
|
|
934
|
+
"""Print the human-readable upgrade plan (what *would* run)."""
|
|
935
|
+
if not migrations:
|
|
936
|
+
click.echo("No migrations registered — nothing to apply.")
|
|
937
|
+
return
|
|
938
|
+
click.echo(f"{len(migrations)} migration(s) would be applied:")
|
|
939
|
+
for migration in migrations:
|
|
940
|
+
click.echo(f" {migration.version} {migration.description}")
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
@db.command()
|
|
944
|
+
@click.option(
|
|
945
|
+
"--database-url",
|
|
946
|
+
"database_url",
|
|
947
|
+
default=None,
|
|
948
|
+
help="Database DSN to migrate (falls back to $AGENTSHIP_DATABASE_URL / $DATABASE_URL).",
|
|
949
|
+
)
|
|
950
|
+
@click.option(
|
|
951
|
+
"--allow-migrations",
|
|
952
|
+
is_flag=True,
|
|
953
|
+
help="Actually apply pending migrations. Without this flag the command is plan-only.",
|
|
954
|
+
)
|
|
955
|
+
@click.option(
|
|
956
|
+
"--debug", is_flag=True, help="Re-raise on an unexpected failure for the full traceback."
|
|
957
|
+
)
|
|
958
|
+
def upgrade(database_url: str | None, allow_migrations: bool, debug: bool) -> None:
|
|
959
|
+
"""Apply pending database migrations — the single, gated owner of all DDL.
|
|
960
|
+
|
|
961
|
+
This is the *only* sanctioned migration runner (§13.9): every phase that
|
|
962
|
+
needs schema registers its idempotent, version-stamped migration in
|
|
963
|
+
``agentship_cli.migrations.REGISTERED_MIGRATIONS`` rather than shipping its
|
|
964
|
+
own runner.
|
|
965
|
+
|
|
966
|
+
**Plan-only by default.** With no ``--allow-migrations`` flag the command
|
|
967
|
+
only *prints* what would run and touches no database — the apply path is
|
|
968
|
+
unreachable, so ungated DDL is impossible by construction.
|
|
969
|
+
|
|
970
|
+
**With ``--allow-migrations``** it applies the pending migrations in
|
|
971
|
+
``version`` order and prints an ``applied N migrations (M pending)``
|
|
972
|
+
summary. If migrations are pending but no DSN is available (neither
|
|
973
|
+
``--database-url`` nor an environment variable), it refuses with a clean
|
|
974
|
+
``Error:`` naming the missing DSN. With zero registered migrations it
|
|
975
|
+
succeeds as a no-op even without a DSN. On an unexpected failure it prints a
|
|
976
|
+
concise ``Error:`` (no traceback) and exits ``1``; ``--debug`` re-raises.
|
|
977
|
+
"""
|
|
978
|
+
migrations = _pending_migrations()
|
|
979
|
+
|
|
980
|
+
if not allow_migrations:
|
|
981
|
+
# Plan-only: never resolve or touch a database. The apply path below is
|
|
982
|
+
# simply not reached, which is what makes ungated DDL impossible.
|
|
983
|
+
_print_plan(migrations)
|
|
984
|
+
click.echo("Plan-only — pass --allow-migrations to apply.")
|
|
985
|
+
return
|
|
986
|
+
|
|
987
|
+
try:
|
|
988
|
+
_apply_migrations(migrations, database_url)
|
|
989
|
+
except AgentShipError as exc:
|
|
990
|
+
click.echo(f"Error: {exc}", err=True)
|
|
991
|
+
sys.exit(1)
|
|
992
|
+
except Exception as exc: # noqa: BLE001 - unexpected; surface cleanly or re-raise
|
|
993
|
+
if debug:
|
|
994
|
+
raise
|
|
995
|
+
click.echo(f"Error: {exc} (run with --debug for the full traceback)", err=True)
|
|
996
|
+
sys.exit(1)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _apply_migrations(migrations: list[Migration], database_url: str | None) -> None:
|
|
1000
|
+
"""Apply ``migrations`` in order against the resolved DSN.
|
|
1001
|
+
|
|
1002
|
+
This is the *only* code path that may run DDL, and it is reachable only from
|
|
1003
|
+
``upgrade`` when ``--allow-migrations`` was passed — that is the gate. With
|
|
1004
|
+
no pending migrations it is a pure no-op and needs no DSN. With pending
|
|
1005
|
+
migrations and no resolvable DSN it raises
|
|
1006
|
+
:class:`~agentship.errors.AgentShipError` naming the missing DSN.
|
|
1007
|
+
"""
|
|
1008
|
+
if not migrations:
|
|
1009
|
+
click.echo("applied 0 migrations (0 pending)")
|
|
1010
|
+
return
|
|
1011
|
+
|
|
1012
|
+
resolved = _resolve_database_url(database_url)
|
|
1013
|
+
if resolved is None:
|
|
1014
|
+
raise AgentShipError(
|
|
1015
|
+
"no database URL — pass --database-url or set "
|
|
1016
|
+
f"{DATABASE_URL_ENV_VARS[0]} (or {DATABASE_URL_ENV_VARS[1]})"
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
for migration in migrations:
|
|
1020
|
+
click.echo(f"applying {migration.version} {migration.description}")
|
|
1021
|
+
migration.apply(resolved)
|
|
1022
|
+
|
|
1023
|
+
click.echo(f"applied {len(migrations)} migrations (0 pending)")
|