gravity-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. gravity_cli/__init__.py +11 -0
  2. gravity_cli/__main__.py +4 -0
  3. gravity_cli/_runner_script.py +195 -0
  4. gravity_cli/api.py +88 -0
  5. gravity_cli/cli.py +124 -0
  6. gravity_cli/commands/__init__.py +0 -0
  7. gravity_cli/commands/adapt.py +155 -0
  8. gravity_cli/commands/build.py +83 -0
  9. gravity_cli/commands/init.py +78 -0
  10. gravity_cli/commands/login.py +58 -0
  11. gravity_cli/commands/push.py +98 -0
  12. gravity_cli/commands/run.py +281 -0
  13. gravity_cli/commands/runs.py +49 -0
  14. gravity_cli/commands/schedule.py +74 -0
  15. gravity_cli/commands/tools.py +27 -0
  16. gravity_cli/commands/validate.py +195 -0
  17. gravity_cli/config.py +91 -0
  18. gravity_cli/manifest.py +19 -0
  19. gravity_cli/project.py +42 -0
  20. gravity_cli/templates/chat/.gitignore +6 -0
  21. gravity_cli/templates/chat/manifest.yaml +28 -0
  22. gravity_cli/templates/chat/requirements.txt +3 -0
  23. gravity_cli/templates/chat/src/agent.py +80 -0
  24. gravity_cli/templates/deepagent/.gitignore +6 -0
  25. gravity_cli/templates/deepagent/manifest.yaml +28 -0
  26. gravity_cli/templates/deepagent/requirements.txt +4 -0
  27. gravity_cli/templates/deepagent/src/agent.py +54 -0
  28. gravity_cli/templates/minimal/.gitignore +6 -0
  29. gravity_cli/templates/minimal/manifest.yaml +30 -0
  30. gravity_cli/templates/minimal/requirements.txt +3 -0
  31. gravity_cli/templates/minimal/src/agent.py +58 -0
  32. gravity_cli/templates/structured/.gitignore +6 -0
  33. gravity_cli/templates/structured/manifest.yaml +33 -0
  34. gravity_cli/templates/structured/requirements.txt +3 -0
  35. gravity_cli/templates/structured/src/agent.py +15 -0
  36. gravity_cli/templates/structured/src/gateway.py +26 -0
  37. gravity_cli/templates/structured/src/nodes/agent.py +20 -0
  38. gravity_cli/templates/structured/src/prompts.py +6 -0
  39. gravity_cli/templates/structured/src/state.py +10 -0
  40. gravity_cli/timeline.py +254 -0
  41. gravity_cli-0.1.0.dist-info/METADATA +494 -0
  42. gravity_cli-0.1.0.dist-info/RECORD +46 -0
  43. gravity_cli-0.1.0.dist-info/WHEEL +4 -0
  44. gravity_cli-0.1.0.dist-info/entry_points.txt +2 -0
  45. gravity_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  46. gravity_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,11 @@
1
+ """gravity — the CLI a builder actually touches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import sys
7
+
8
+ # Windows consoles default to cp1252, which can't print ✓/✖/→. Here so nothing prints first.
9
+ for _stream in (sys.stdout, sys.stderr):
10
+ with contextlib.suppress(Exception):
11
+ _stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,195 @@
1
+ """Runs the builder's agent in their own venv, as the production runner does. Stdlib only.
2
+
3
+ argv: entry_file entry_object payload_file a JSON file: {"inputs", "messages"?, "memory"?}
4
+ out: one JSON event per line (EVENT_PREFIX) for `gravity run` to draw."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import importlib.util
10
+ import inspect
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ import warnings
16
+
17
+ # A harmless langchain-openai warning that would print mid-timeline.
18
+ warnings.filterwarnings("ignore", message=r"Pydantic serializer warnings")
19
+
20
+
21
+ def _load_graph(entry_file: str, entry_object: str):
22
+ # src/ on sys.path, as in production, so a relative import fails here too.
23
+ file_path = os.path.abspath(entry_file)
24
+ pkg_root = os.path.dirname(file_path)
25
+ if pkg_root not in sys.path:
26
+ sys.path.insert(0, pkg_root)
27
+ spec = importlib.util.spec_from_file_location("gravity_agent", file_path)
28
+ if spec is None or spec.loader is None:
29
+ raise ImportError(f"could not load {entry_file}")
30
+ module = importlib.util.module_from_spec(spec)
31
+ sys.modules["gravity_agent"] = module
32
+ spec.loader.exec_module(module)
33
+ if not hasattr(module, entry_object):
34
+ raise AttributeError(
35
+ f"{entry_file} has no `{entry_object}` — check runtime.entrypoint in manifest.yaml"
36
+ )
37
+ return getattr(module, entry_object)
38
+
39
+
40
+ async def _resolve(obj):
41
+ # Same rules as the production runner: await, or call a zero-arg factory, in this loop.
42
+ if inspect.isawaitable(obj):
43
+ return await obj
44
+ if callable(obj) and not hasattr(obj, "astream"):
45
+ obj = obj()
46
+ if inspect.isawaitable(obj):
47
+ obj = await obj
48
+ return obj
49
+
50
+
51
+ EVENT_PREFIX = "@@gravity "
52
+ ROLES = {"human": "user", "ai": "assistant", "system": "system"}
53
+
54
+
55
+ # Same three helpers as agentcore-spike/runner/gravity_runner/__main__.py.
56
+ def accepts_messages(graph) -> bool:
57
+ # A compiled graph's channels are its state keys; no schema build (raises on py<3.12).
58
+ channels = getattr(graph, "channels", None)
59
+ if isinstance(channels, dict):
60
+ return "messages" in channels
61
+ getter = getattr(graph, "get_input_jsonschema", None)
62
+ if callable(getter):
63
+ try:
64
+ return "messages" in (getter().get("properties") or {})
65
+ except Exception: # noqa: BLE001, S110 - a probe; absence is a valid answer
66
+ pass
67
+ return False
68
+
69
+
70
+ def user_turn(inputs: dict) -> str:
71
+ for key in ("message", "query", "prompt", "input"):
72
+ value = inputs.get(key)
73
+ if isinstance(value, str) and value.strip():
74
+ return value
75
+ return json.dumps(inputs)
76
+
77
+
78
+ def to_dicts(messages) -> list[dict]:
79
+ """LangChain messages -> {role, content}; tool turns dropped (half a pair breaks the next turn)."""
80
+ out = []
81
+ for m in messages or []:
82
+ if isinstance(m, dict):
83
+ role, content = m.get("role"), m.get("content")
84
+ else:
85
+ if getattr(m, "tool_calls", None) or getattr(m, "type", None) == "tool":
86
+ continue
87
+ role, content = ROLES.get(getattr(m, "type", ""), ""), getattr(m, "content", None)
88
+ if role in ROLES.values() and isinstance(content, str) and content.strip():
89
+ out.append({"role": role, "content": content})
90
+ return out
91
+
92
+
93
+ def emit(type_: str, **fields) -> None:
94
+ line = json.dumps({"type": type_, **fields}, default=str, ensure_ascii=False)
95
+ print(EVENT_PREFIX + line, flush=True)
96
+
97
+
98
+ def _text(value) -> str:
99
+ """A tool's output as text: ToolMessage, MCP content blocks, or a str."""
100
+ value = getattr(value, "content", value)
101
+ if isinstance(value, list):
102
+ value = "".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in value)
103
+ return value if isinstance(value, str) else json.dumps(value, default=str)
104
+
105
+
106
+ def _handle(ev: dict, started: dict) -> None:
107
+ """One LangGraph astream_events(v2) event -> zero or one gravity event."""
108
+ kind, name = ev["event"], ev["name"]
109
+ meta = ev.get("metadata") or {}
110
+ node = meta.get("langgraph_node")
111
+ top_level = "|" not in (meta.get("langgraph_checkpoint_ns") or "")
112
+ run_id = ev.get("run_id")
113
+
114
+ if kind in ("on_chain_start", "on_chain_end") and name == node and top_level:
115
+ if kind == "on_chain_start":
116
+ started[run_id] = time.perf_counter()
117
+ emit("step.started", node=name)
118
+ else:
119
+ emit("step.finished", node=name, ms=_ms(started, run_id))
120
+ elif kind == "on_chat_model_start":
121
+ started[run_id] = time.perf_counter()
122
+ elif kind == "on_chat_model_end":
123
+ emit("model.called", node=node, model=meta.get("ls_model_name") or name, ms=_ms(started, run_id))
124
+ elif kind == "on_tool_start":
125
+ # At the start too, so a call held for approval is visible while it waits.
126
+ started[run_id] = time.perf_counter()
127
+ emit("tool.started", node=node, tool=name, id=run_id)
128
+ elif kind == "on_tool_end":
129
+ emit("tool.called", node=node, tool=name, id=run_id, ms=_ms(started, run_id), output=_text(ev["data"].get("output"))[:100_000])
130
+ elif kind == "on_tool_error":
131
+ emit("tool.failed", node=node, tool=name, id=run_id, ms=_ms(started, run_id), error=str(ev["data"].get("error")))
132
+
133
+
134
+ def _ms(started: dict, run_id) -> int | None:
135
+ t = started.pop(run_id, None)
136
+ return round((time.perf_counter() - t) * 1000) if t else None
137
+
138
+
139
+ async def _consume_async(graph, state, history):
140
+ graph = await _resolve(graph)
141
+ if accepts_messages(graph):
142
+ state["messages"] = history or [{"role": "user", "content": user_turn(state["inputs"])}]
143
+ if not hasattr(graph, "astream_events"):
144
+ # Not LangGraph: no timeline; plain astream(state), folding the deltas.
145
+ final: dict = {}
146
+ async for update in graph.astream(state):
147
+ if isinstance(update, dict):
148
+ for value in update.values() if all(isinstance(v, dict) for v in update.values()) else [update]:
149
+ final.update(value)
150
+ return final
151
+ final, started = None, {}
152
+ async for ev in graph.astream_events(state, version="v2"):
153
+ _handle(ev, started)
154
+ if ev["event"] == "on_chain_end" and not ev.get("parent_ids"):
155
+ final = ev["data"].get("output")
156
+ return final
157
+
158
+
159
+ def main() -> int:
160
+ entry_file, entry_object = sys.argv[1], sys.argv[2]
161
+ with open(sys.argv[3], encoding="utf-8") as f:
162
+ payload = json.load(f)
163
+ history = payload.get("messages")
164
+ state = {"inputs": payload.get("inputs") or {}}
165
+ if "memory" in payload:
166
+ state["memory"] = payload["memory"] or {}
167
+
168
+ try:
169
+ graph = _load_graph(entry_file, entry_object)
170
+ except Exception as e: # noqa: BLE001 — surfaced to the builder, not swallowed
171
+ emit("run.failed", stage="load", error=f"could not load the agent: {e}")
172
+ return 1
173
+
174
+ # astream: agent graphs have async nodes, so the sync stream() fails on them.
175
+ start = time.time()
176
+ try:
177
+ final = asyncio.run(_consume_async(graph, state, history))
178
+ except Exception as e: # noqa: BLE001
179
+ emit("run.failed", stage="run", error=str(e), ms=round((time.time() - start) * 1000))
180
+ return 1
181
+
182
+ final = final if isinstance(final, dict) else {}
183
+ emit(
184
+ "run.finished",
185
+ ms=round((time.time() - start) * 1000),
186
+ has_result="result" in final,
187
+ result=final.get("result"),
188
+ messages=to_dicts(final.get("messages")) if "messages" in state else None,
189
+ memory=final.get("memory") if "memory" in state else None,
190
+ )
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ sys.exit(main())
gravity_cli/api.py ADDED
@@ -0,0 +1,88 @@
1
+ """Thin client for the control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+ import typer
10
+ from rich import print as rprint
11
+
12
+ from . import config
13
+
14
+
15
+ def _handle(resp: httpx.Response) -> Any:
16
+ """The JSON body, or print the server's message and exit 1."""
17
+ if resp.status_code >= 400:
18
+ try:
19
+ body = resp.json()
20
+ message = body.get("message") or body
21
+ except (ValueError, AttributeError): # not JSON, or not an object
22
+ message = resp.text
23
+ if isinstance(message, list):
24
+ message = "; ".join(str(m) for m in message)
25
+ rprint(f"[red]✖[/red] {message}")
26
+ raise typer.Exit(1)
27
+ if resp.status_code == 204 or not resp.content:
28
+ return None
29
+ return resp.json()
30
+
31
+
32
+ def _post(path: str, **kw) -> Any:
33
+ """Unauthenticated: the device-code login runs before there is a token."""
34
+ with httpx.Client(base_url=config.api_url(), timeout=15) as client:
35
+ return _handle(client.post(path, **kw))
36
+
37
+
38
+ def device_start() -> dict:
39
+ return _post("/api/cli/device/start")
40
+
41
+
42
+ def device_poll(device_code: str) -> dict:
43
+ return _post("/api/cli/device/poll", json={"deviceCode": device_code})
44
+
45
+
46
+ def require_session() -> config.Session:
47
+ session = config.load_session()
48
+ if session is None:
49
+ typer.secho(
50
+ "not logged in — run `gravity login` first, or set GRAVITY_API_TOKEN.",
51
+ fg=typer.colors.RED,
52
+ err=True,
53
+ )
54
+ raise typer.Exit(1)
55
+ return session
56
+
57
+
58
+ def _token() -> str:
59
+ # GRAVITY_API_TOKEN (the control plane's dev token) wins over a stored session.
60
+ return os.environ.get("GRAVITY_API_TOKEN", "").strip() or require_session().access_token
61
+
62
+
63
+ def _auth_client() -> httpx.Client:
64
+ return httpx.Client(
65
+ base_url=config.api_url(),
66
+ timeout=60,
67
+ headers={"Authorization": f"Bearer {_token()}"},
68
+ )
69
+
70
+
71
+ def get(path: str) -> Any:
72
+ with _auth_client() as client:
73
+ return _handle(client.get(path))
74
+
75
+
76
+ def push_agent(manifest_json: dict, zip_bytes: bytes, source_sha256: str) -> dict:
77
+ import base64
78
+ import json
79
+
80
+ headers = {
81
+ "Authorization": f"Bearer {_token()}",
82
+ "Content-Type": "application/octet-stream",
83
+ "x-manifest": base64.b64encode(json.dumps(manifest_json).encode()).decode(),
84
+ "x-source-sha256": source_sha256,
85
+ }
86
+ with httpx.Client(base_url=config.api_url(), timeout=120, headers=headers) as client:
87
+ resp = client.post("/api/cli/agents/push", content=zip_bytes)
88
+ return _handle(resp)
gravity_cli/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ """gravity CLI. Convenience only, never security: the server re-checks everything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+ import typer.rich_utils as help_style
9
+ from rich.console import Console, Group
10
+ from rich.panel import Panel
11
+ from rich.prompt import Confirm, Prompt
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from . import config
16
+ from .commands.adapt import adapt_command
17
+ from .commands.build import build_command
18
+ from .commands.init import TEMPLATE_NAMES, init_command
19
+ from .commands.login import login_command, logout_command, whoami_command
20
+ from .commands.push import push_command
21
+ from .commands.run import run_command
22
+ from .commands.runs import agents_command, logs_command, runs_command
23
+ from .commands.schedule import schedule_command
24
+ from .commands.tools import tools_command
25
+ from .commands.validate import validate_command
26
+
27
+ EMBER, AMBER, RED = "#ff6b35", "#ffb627", "#e63946"
28
+
29
+ help_style.STYLE_USAGE = f"bold {EMBER}"
30
+ help_style.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {EMBER}"
31
+ help_style.STYLE_OPTION = help_style.STYLE_SWITCH = f"bold {AMBER}"
32
+ help_style.STYLE_COMMANDS_PANEL_BORDER = help_style.STYLE_OPTIONS_PANEL_BORDER = AMBER
33
+ help_style.STYLE_ERRORS_PANEL_BORDER = RED
34
+
35
+ app = typer.Typer(
36
+ name="gravity",
37
+ help="Build, test and publish AI agents on Gravity.",
38
+ epilog=(
39
+ f"[bold {EMBER}]New here?[/] gravity init my-agent → cd my-agent → gravity run --input query=\"hi\"\n\n"
40
+ "Settings live in ~/.gravity/.env. Add -h to any command for its options."
41
+ ),
42
+ rich_markup_mode="rich",
43
+ context_settings={"help_option_names": ["-h", "--help"]},
44
+ add_completion=False,
45
+ )
46
+
47
+ BUILD, PUBLISH, AGENTS, ACCOUNT = "Build an agent", "Publish", "Your agents", "Account"
48
+ for name, command, panel, summary in [
49
+ ("init", init_command, BUILD, "Create a new agent from a template"),
50
+ ("validate", validate_command, BUILD, "Check your agent for mistakes before it runs"),
51
+ ("run", run_command, BUILD, "Run your agent locally against real tools"),
52
+ ("schedule", schedule_command, BUILD, "Run your agent on its schedule, locally"),
53
+ ("adapt", adapt_command, BUILD, "Write a new slot adapter with AI, kept only if its tests pass"),
54
+ ("build", build_command, PUBLISH, "Package your agent into build/agent.zip"),
55
+ ("push", push_command, PUBLISH, "Publish your built agent to Gravity"),
56
+ ("agents", agents_command, AGENTS, "List your published agents and their review status"),
57
+ ("runs", runs_command, AGENTS, "Show recent runs of one agent"),
58
+ ("logs", logs_command, AGENTS, "Show the logs of one run"),
59
+ ("tools", tools_command, AGENTS, "Browse the tools your agent can use"),
60
+ ("login", login_command, ACCOUNT, "Sign in to Gravity in your browser"),
61
+ ("logout", logout_command, ACCOUNT, "Sign out"),
62
+ ("whoami", whoami_command, ACCOUNT, "Show who you are signed in as"),
63
+ ]:
64
+ app.command(name, help=summary, rich_help_panel=panel)(command)
65
+
66
+
67
+ @app.command("help", rich_help_panel=ACCOUNT)
68
+ def help_command(ctx: typer.Context):
69
+ """Show this list of commands"""
70
+ ctx.parent.get_help()
71
+
72
+
73
+ def _interactive() -> bool:
74
+ return sys.stdin.isatty() and sys.stderr.isatty() # a person is there to answer
75
+
76
+
77
+ def _welcome(console: Console) -> None:
78
+ steps = Table.grid(padding=(0, 2))
79
+ steps.add_column(style=f"bold {EMBER}", justify="right")
80
+ steps.add_column(style=f"bold {AMBER}")
81
+ steps.add_column(style="dim")
82
+ steps.add_row("1", "gravity init my-agent", "create a working agent from a template")
83
+ steps.add_row("2", "gravity validate", "catch mistakes before anything runs")
84
+ steps.add_row("3", "gravity run", "run it against real tools, live")
85
+ steps.add_row("4", "gravity push", "publish it to Gravity")
86
+ body = Group(
87
+ Text.from_markup(
88
+ "Build AI agents in plain Python with LangGraph. Declare what they may touch in one "
89
+ "[bold]manifest.yaml[/bold], and Gravity runs them with exactly those permissions.\n"
90
+ ),
91
+ steps,
92
+ Text.from_markup(f"\n[dim]Stuck? [bold {AMBER}]gravity help[/] lists every command.[/dim]"),
93
+ )
94
+ console.print(Panel(body, title=f"[bold {EMBER}]✦ Welcome to Gravity ✦", border_style=AMBER, padding=(1, 2), expand=False))
95
+
96
+
97
+ def _first_agent(console: Console) -> bool:
98
+ """Offer to scaffold an agent right away. True if one was created."""
99
+ try:
100
+ if not Confirm.ask(f"[bold {AMBER}]Create your first agent now?", default=True, console=console):
101
+ return False
102
+ name = Prompt.ask(" Agent name", default="my-agent", console=console)
103
+ template = Prompt.ask(" Template", choices=TEMPLATE_NAMES, default="minimal", console=console)
104
+ except (EOFError, KeyboardInterrupt):
105
+ console.print()
106
+ return False
107
+ init_command(name, template=template, here=False)
108
+ return True
109
+
110
+
111
+ @app.callback(invoke_without_command=True)
112
+ def main(
113
+ ctx: typer.Context,
114
+ api_url: str = typer.Option(None, "--api-url", help="Save a different Gravity API URL (or set GRAVITY_API_URL)"),
115
+ ):
116
+ if config.first_run():
117
+ console = Console(stderr=True) # stderr: never mixed into output someone pipes
118
+ _welcome(console)
119
+ if ctx.invoked_subcommand is None and _interactive() and _first_agent(console):
120
+ raise typer.Exit()
121
+ if api_url:
122
+ config.set_api_url(api_url)
123
+ if ctx.invoked_subcommand is None:
124
+ ctx.get_help()
File without changes
@@ -0,0 +1,155 @@
1
+ """`gravity adapt <slot> <name> --tools ...`: a coding agent writes one adapter file, kept only
2
+ if the slot's conformance tests pass. Naming the tools is the grant."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+ import typer
14
+ from rich import print as rprint
15
+ from rich.markup import escape
16
+
17
+ from .. import project
18
+ from ..manifest import ManifestValidationError, load_manifest, render_issues
19
+ from .run import BUILDER_TOKENS, _mint_run_token, gateway_urls
20
+
21
+ ATTEMPTS = 3
22
+ CODE_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL)
23
+
24
+ SYSTEM = (
25
+ "You write one adapter file for a Python agent. Follow the slot contract exactly. Reach "
26
+ "the outside world only through the `call` function you are given, only with the tools "
27
+ "you are told this adapter may use, with arguments that match their schemas. Stdlib and "
28
+ "the slot package only. Match the style of the existing adapters. Reply with the whole "
29
+ "file in one ```python block and nothing else."
30
+ )
31
+
32
+
33
+ def _declare(manifest_path: Path, slot: str, name: str, tools: list[str]) -> str:
34
+ """Add `<name>: [tools]` under slots.<slot>.adapters. Returns the original text."""
35
+ original = manifest_path.read_text(encoding="utf-8")
36
+ lines = original.splitlines(keepends=True)
37
+ slot_at = next(i for i, ln in enumerate(lines) if re.match(rf"^\s+{slot}:\s*$", ln))
38
+ adapters_at = next(i for i in range(slot_at + 1, len(lines)) if lines[i].strip() == "adapters:")
39
+ indent = len(lines[adapters_at]) - len(lines[adapters_at].lstrip())
40
+ end = adapters_at + 1
41
+ while end < len(lines) and (not lines[end].strip() or len(lines[end]) - len(lines[end].lstrip()) > indent):
42
+ end += 1
43
+ while not lines[end - 1].strip(): # keep the new entry above any blank line
44
+ end -= 1
45
+ lines.insert(end, f"{' ' * (indent + 2)}{name}: [{', '.join(tools)}]\n")
46
+ manifest_path.write_text("".join(lines), encoding="utf-8")
47
+ return original
48
+
49
+
50
+ def _ask(gateway_base: str, token: str, model: str, messages: list[dict]) -> str:
51
+ resp = httpx.post(
52
+ f"{gateway_base}/v1/chat/completions",
53
+ json={"model": model, "messages": messages},
54
+ headers={"Authorization": f"Bearer {token}"},
55
+ timeout=180,
56
+ )
57
+ resp.raise_for_status()
58
+ return resp.json()["choices"][0]["message"]["content"]
59
+
60
+
61
+ def adapt_command(
62
+ slot: str = typer.Argument(..., help="The slot to extend, e.g. deliver"),
63
+ name: str = typer.Argument(..., help="Name of the new adapter, e.g. slack"),
64
+ tools: str = typer.Option(..., "--tools", help="Tools the adapter may call, comma-separated. This is what it is granted"),
65
+ hint: str = typer.Option("", "--hint", help="Anything the coding agent should know, e.g. 'post to #leads'"),
66
+ model: str = typer.Option("anthropic/claude-sonnet-5", "--model", help="Model that writes the adapter"),
67
+ path: Path = typer.Option(Path("."), "--path", help="Agent folder"),
68
+ ):
69
+ """Write a new adapter for a slot with a coding agent, gated by the slot's conformance tests."""
70
+ project_dir = path.resolve()
71
+ manifest_path = project_dir / "manifest.yaml"
72
+ manifest = load_manifest(manifest_path)
73
+ if slot not in manifest.slots:
74
+ rprint(f"[red]✖[/red] no slot {slot!r} in manifest.yaml (slots: {', '.join(manifest.slots) or 'none'})")
75
+ raise typer.Exit(1)
76
+ if not name.isidentifier() or name in manifest.slots[slot].adapters:
77
+ rprint(f"[red]✖[/red] {name!r} is not a new adapter name for {slot}")
78
+ raise typer.Exit(1)
79
+ folder = project_dir / "src" / "adapters" / slot
80
+ target = folder / f"{name}.py"
81
+ test_file = project_dir / "tests" / f"test_{slot}_adapters.py"
82
+ if not test_file.is_file():
83
+ rprint(f"[red]✖[/red] no tests/test_{slot}_adapters.py — the conformance tests are what make this safe")
84
+ raise typer.Exit(1)
85
+ grant = [t.strip() for t in tools.split(",") if t.strip()]
86
+
87
+ # Declare first so validation (and the tests) see the adapter with exactly its grant.
88
+ original = _declare(manifest_path, slot, name, grant)
89
+ try:
90
+ load_manifest(manifest_path) # catalog check on the new tools
91
+ except ManifestValidationError as e:
92
+ manifest_path.write_text(original, encoding="utf-8")
93
+ render_issues(e)
94
+ raise typer.Exit(1) from e
95
+
96
+ _, gateway_base = gateway_urls()
97
+ dev = {"Authorization": f"Bearer {os.environ.get('GRAVITY_DEV_TOKEN', '')}"}
98
+ all_tools = sorted({t for tl in manifest.slots[slot].adapters.values() for t in tl} | set(grant))
99
+ try:
100
+ schemas = httpx.get(f"{gateway_base}/tools", params={"names": ",".join(all_tools)}, headers=dev, timeout=15)
101
+ schemas.raise_for_status()
102
+ except httpx.HTTPError as e:
103
+ manifest_path.write_text(original, encoding="utf-8")
104
+ rprint(f"[red]✖[/red] couldn't read tool schemas from the gateway at {gateway_base}: {e}")
105
+ raise typer.Exit(1) from e
106
+ (project_dir / "tests" / "tool_schemas.json").write_text(json.dumps(schemas.json(), indent=2), encoding="utf-8")
107
+
108
+ def read(p: Path) -> str:
109
+ return f"### {p.relative_to(project_dir).as_posix()}\n```python\n{p.read_text(encoding='utf-8')}```"
110
+
111
+ gold = [read(p) for p in sorted(folder.glob("*.py")) if p != target]
112
+ new_schemas = [s for s in schemas.json() if s["name"] in grant]
113
+ messages = [
114
+ {"role": "system", "content": SYSTEM},
115
+ {"role": "user", "content": "\n\n".join([
116
+ f"Write src/adapters/{slot}/{name}.py. It may call only: {', '.join(grant)}.",
117
+ f"Their argument schemas:\n```json\n{json.dumps(new_schemas, indent=2)}\n```",
118
+ "The slot contract and the existing adapters:", *gold,
119
+ "It must pass these conformance tests:", read(test_file),
120
+ *( [f"Notes from the user: {hint}"] if hint else [] ),
121
+ ])},
122
+ ]
123
+
124
+ token = _mint_run_token(gateway_base, [], [])["token"] # models only, no tools
125
+ python = project.find_python(project_dir)
126
+ test_env = {k: v for k, v in os.environ.items() if k not in BUILDER_TOKENS} # it runs LLM-written code
127
+ for attempt in range(1, ATTEMPTS + 1):
128
+ rprint(f"[dim]attempt {attempt}/{ATTEMPTS}: asking {model}…[/dim]")
129
+ reply = _ask(gateway_base, token, model, messages)
130
+ match = CODE_BLOCK.search(reply)
131
+ code = match.group(1) if match else reply
132
+ target.write_text(code, encoding="utf-8") # the only file it ever writes
133
+ result = subprocess.run(
134
+ [python, "-m", "pytest", "-q", str(test_file), "-k", name],
135
+ cwd=project_dir, env=test_env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
136
+ )
137
+ if result.returncode == 0:
138
+ rprint(f"[green]✓[/green] src/adapters/{slot}/{name}.py passes the {slot} conformance tests")
139
+ rprint(f"[green]✓[/green] declared in manifest.yaml: slots.{slot}.adapters.{name}: " + escape(f"[{', '.join(grant)}]"))
140
+ rprint(f" try it: [bold]gravity run --input {slot}={name} --input destination=…[/bold]")
141
+ return
142
+ failure = (result.stdout + result.stderr)[-3000:]
143
+ if "No module named pytest" in failure:
144
+ break
145
+ rprint("[yellow]✗[/yellow] tests failed; feeding the failure back")
146
+ messages += [{"role": "assistant", "content": reply},
147
+ {"role": "user", "content": f"The conformance tests failed:\n```\n{failure}\n```\nFix the file."}]
148
+
149
+ target.unlink(missing_ok=True)
150
+ manifest_path.write_text(original, encoding="utf-8")
151
+ rprint(f"[red]✖[/red] no adapter that passes the tests after {attempt} attempt(s); nothing was changed")
152
+ rprint("[dim]" + escape(failure.strip()[-1500:]) + "[/dim]")
153
+ if "No module named pytest" in failure:
154
+ rprint("[dim]install pytest in the agent's venv: uv pip install pytest pyyaml[/dim]")
155
+ raise typer.Exit(1)
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import zipfile
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich import print as rprint
12
+
13
+ from ..manifest import ManifestValidationError, load_manifest, render_issues
14
+ from ..project import project_files, source_digest
15
+ from .validate import validate_command
16
+
17
+
18
+ def build_paths(project_dir: Path) -> tuple[Path, Path]:
19
+ """(zip, meta) — the two files `gravity build` writes and `gravity push` reads."""
20
+ d = project_dir / "build"
21
+ return d / "agent.zip", d / "build.json"
22
+
23
+
24
+ def _zip_project(project_dir: Path) -> bytes:
25
+ """Deterministic (fixed timestamps, sorted entries): same source, same sha256."""
26
+ buf = io.BytesIO()
27
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
28
+ for f in project_files(project_dir):
29
+ info = zipfile.ZipInfo(f.relative_to(project_dir).as_posix())
30
+ info.date_time = (1980, 1, 1, 0, 0, 0)
31
+ info.compress_type = zipfile.ZIP_DEFLATED
32
+ zf.writestr(info, f.read_bytes())
33
+ return buf.getvalue()
34
+
35
+
36
+ def build_command(
37
+ path: Path = typer.Argument(Path("."), help="Agent folder"),
38
+ skip_validate: bool = typer.Option(False, "--skip-validate", hidden=True),
39
+ ):
40
+ """Validate, then bundle src + manifest + lockfile into build/agent.zip.
41
+ Nothing leaves your machine — inspect the zip, then `gravity push`."""
42
+ project_dir = path.resolve()
43
+
44
+ if not skip_validate:
45
+ try:
46
+ validate_command(project_dir)
47
+ except typer.Exit as e:
48
+ if e.exit_code != 0:
49
+ rprint("[red]build aborted — fix the errors above and re-run.[/red]")
50
+ raise
51
+
52
+ try:
53
+ manifest = load_manifest(project_dir / "manifest.yaml")
54
+ except ManifestValidationError as e:
55
+ render_issues(e)
56
+ raise typer.Exit(1) from e
57
+
58
+ # After validate recompiled the lock, so the digest covers what ships.
59
+ zip_bytes = _zip_project(project_dir)
60
+ digest = source_digest(project_dir)
61
+ zip_sha256 = hashlib.sha256(zip_bytes).hexdigest()
62
+
63
+ zip_path, meta_path = build_paths(project_dir)
64
+ zip_path.parent.mkdir(exist_ok=True)
65
+ zip_path.write_bytes(zip_bytes)
66
+ meta_path.write_text(
67
+ json.dumps(
68
+ {
69
+ "source_sha256": digest,
70
+ "zip_sha256": zip_sha256,
71
+ "built_at": datetime.now(UTC).isoformat(timespec="seconds"),
72
+ },
73
+ indent=2,
74
+ )
75
+ + "\n"
76
+ )
77
+
78
+ rel = zip_path.relative_to(project_dir).as_posix()
79
+ rprint(
80
+ f"[green]✓ built {manifest.name}@{manifest.version}[/green] → {rel} "
81
+ f"· {len(zip_bytes)} bytes · sha256:{zip_sha256[:12]}…"
82
+ )
83
+ rprint("[dim]next: gravity push[/dim]")