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 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)")