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,78 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from gravity_schema import AGENT_NAME_RE
9
+ from rich import print as rprint
10
+
11
+ TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
12
+ TEMPLATE_NAMES = ["minimal", "structured", "deepagent", "chat"]
13
+
14
+
15
+ def _slugify(raw: str) -> str:
16
+ """A directory name is not necessarily a valid agent name."""
17
+ slug = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")
18
+ return re.sub(r"-{2,}", "-", slug)
19
+
20
+
21
+ def init_command(
22
+ name: str = typer.Argument(
23
+ None, help="Name of the agent, and of the folder created"
24
+ ),
25
+ template: str = typer.Option(
26
+ "minimal", "--template", help=f"Starting point: {', '.join(TEMPLATE_NAMES)}"
27
+ ),
28
+ here: bool = typer.Option(
29
+ False, "--here", help="Add only a manifest.yaml to the current folder, for an existing project"
30
+ ),
31
+ ):
32
+ """Scaffold a working agent: manifest, pinned deps and a src/agent.py that runs."""
33
+ if template not in TEMPLATE_NAMES:
34
+ rprint(f"[red]unknown template {template!r}[/red] — pick one of: {', '.join(TEMPLATE_NAMES)}")
35
+ raise typer.Exit(1)
36
+
37
+ # --here takes its name from the directory.
38
+ if not name:
39
+ if not here:
40
+ rprint("[red]✖[/red] `gravity init` needs a name, e.g. `gravity init my-agent`.")
41
+ rprint(" [dim]or `gravity init --here` inside an existing agent repo[/dim]")
42
+ raise typer.Exit(1)
43
+ name = _slugify(Path.cwd().name)
44
+
45
+ if not AGENT_NAME_RE.match(name):
46
+ rprint(f"[red]✖[/red] {name!r} is not a valid agent name.")
47
+ rprint(" [dim]lowercase letters, digits and single hyphens, e.g. 'inbox-triager'[/dim]")
48
+ raise typer.Exit(1)
49
+
50
+ src_dir = TEMPLATES_DIR / template
51
+ manifest_text = (src_dir / "manifest.yaml").read_text(encoding="utf-8").replace("{{name}}", name)
52
+
53
+ if here:
54
+ dest = Path.cwd()
55
+ manifest_dest = dest / "manifest.yaml"
56
+ if manifest_dest.exists():
57
+ rprint(f"[red]manifest.yaml already exists in {dest}[/red] — not overwriting.")
58
+ raise typer.Exit(1)
59
+ manifest_dest.write_text(manifest_text, encoding="utf-8")
60
+ rprint(f"[green]wrote {manifest_dest}[/green]")
61
+ rprint(
62
+ "edit permissions.tools and runtime.entrypoint to match your existing agent, "
63
+ "then `gravity validate`."
64
+ )
65
+ return
66
+
67
+ dest = Path.cwd() / name
68
+ if dest.exists():
69
+ rprint(f"[red]{dest} already exists[/red]")
70
+ raise typer.Exit(1)
71
+
72
+ shutil.copytree(src_dir, dest)
73
+ (dest / "manifest.yaml").write_text(manifest_text, encoding="utf-8")
74
+
75
+ rprint(f"[green]created {dest}[/green] ({template} template)")
76
+ rprint(f"\n cd {name}")
77
+ rprint(" uv venv && uv pip install -r requirements.txt")
78
+ rprint(' gravity run --input query="hello"')
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import webbrowser
5
+
6
+ import typer
7
+ from rich import print as rprint
8
+
9
+ from .. import api, config
10
+
11
+
12
+ def login_command():
13
+ """Log in with a browser device code; the token goes in the OS keychain."""
14
+ start = api.device_start()
15
+ device_code = start["deviceCode"]
16
+ user_code = start["userCode"]
17
+ verification_url = start["verificationUrl"]
18
+ interval = start.get("interval", 3)
19
+ expires_in = start.get("expiresIn", 600)
20
+
21
+ rprint(f"opening {verification_url} in your browser…")
22
+ rprint(f"[dim]if it doesn't open, visit the link above and enter code: [bold]{user_code}[/bold][/dim]")
23
+ webbrowser.open(verification_url)
24
+ rprint("[dim]waiting for approval…[/dim]")
25
+
26
+ deadline = time.time() + expires_in
27
+ while time.time() < deadline:
28
+ time.sleep(interval)
29
+ result = api.device_poll(device_code)
30
+
31
+ if result["status"] == "approved":
32
+ session_data = result["session"]
33
+ config.save_session(
34
+ config.Session(
35
+ access_token=session_data["accessToken"],
36
+ refresh_token=session_data["refreshToken"],
37
+ )
38
+ )
39
+ rprint("[green]✓ logged in[/green]")
40
+ return
41
+
42
+ rprint("[red]✖[/red] timed out waiting for approval — run `gravity login` again.")
43
+ raise typer.Exit(1)
44
+
45
+
46
+ def whoami_command():
47
+ """If you forget who you are."""
48
+ me = api.get("/api/cli/me")
49
+ me = me if isinstance(me, dict) else {}
50
+ role = me.get("userType", "")
51
+ # Only the dev token (GRAVITY_API_TOKEN) is a user without an email.
52
+ who = me.get("email") or f"{me.get('id', '')} (dev token)"
53
+ rprint(f"{who}{f' [dim]· {role}[/dim]' if role else ''}")
54
+
55
+
56
+ def logout_command():
57
+ config.clear_session()
58
+ rprint("[green]✓ logged out[/green]")
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich import print as rprint
10
+
11
+ from .. import api
12
+ from ..manifest import ManifestValidationError, load_manifest, render_issues
13
+ from ..project import source_digest
14
+ from .build import build_paths
15
+
16
+
17
+ def push_command(
18
+ path: Path = typer.Argument(Path("."), help="Agent folder"),
19
+ ):
20
+ """Upload what `gravity build` made; refuses if there's no build or it's stale."""
21
+ project_dir = path.resolve()
22
+ zip_path, meta_path = build_paths(project_dir)
23
+
24
+ if not zip_path.is_file() or not meta_path.is_file():
25
+ rprint("[red]✖[/red] no build found — run `gravity build` first.")
26
+ raise typer.Exit(1)
27
+
28
+ try:
29
+ meta = json.loads(meta_path.read_text())
30
+ except (json.JSONDecodeError, OSError) as e:
31
+ rprint(f"[red]✖[/red] {meta_path.name} is unreadable — re-run `gravity build`.")
32
+ raise typer.Exit(1) from e
33
+
34
+ zip_bytes = zip_path.read_bytes()
35
+ zip_sha256 = hashlib.sha256(zip_bytes).hexdigest()
36
+ if zip_sha256 != meta.get("zip_sha256"):
37
+ rprint(
38
+ f"[red]✖[/red] {zip_path.name} doesn't match {meta_path.name} — re-run `gravity build`."
39
+ )
40
+ raise typer.Exit(1)
41
+
42
+ if source_digest(project_dir) != meta.get("source_sha256"):
43
+ rprint(
44
+ "[red]✖[/red] build is stale — src/, manifest or requirements changed since "
45
+ f"`gravity build` ran ({meta.get('built_at', 'unknown time')}). re-run `gravity build`."
46
+ )
47
+ raise typer.Exit(1)
48
+
49
+ try:
50
+ manifest = load_manifest(project_dir / "manifest.yaml")
51
+ except ManifestValidationError as e:
52
+ render_issues(e)
53
+ raise typer.Exit(1) from e
54
+
55
+ rprint(f"[dim]pushing {zip_path.name} · {len(zip_bytes)} bytes · sha256:{zip_sha256[:12]}…[/dim]")
56
+
57
+ result = api.push_agent(manifest.model_dump(mode="json"), zip_bytes, zip_sha256)
58
+
59
+ rprint(
60
+ f"[green]✓ {result['name']}@{result['version']}[/green] "
61
+ f"— status: {result['status']}"
62
+ )
63
+ if result.get("message"):
64
+ rprint(f"[dim]{result['message']}[/dim]")
65
+
66
+ if result["status"] in BUILDING:
67
+ _wait_for_build(result["name"], result["version"], result["status"])
68
+
69
+
70
+ BUILDING = ("queued", "building")
71
+ POLL_SECONDS = 5
72
+ # Only stops the CLI waiting forever; the build machine gives up well before.
73
+ MAX_WAIT_SECONDS = 45 * 60
74
+
75
+
76
+ def _wait_for_build(name: str, version: str, status: str) -> None:
77
+ """Poll the version until the build machine marks it ready or failed."""
78
+ waited = 0
79
+ v: dict = {}
80
+ try:
81
+ while status in BUILDING and waited < MAX_WAIT_SECONDS:
82
+ time.sleep(POLL_SECONDS)
83
+ waited += POLL_SECONDS
84
+ v = api.get(f"/api/cli/agents/{name}/versions/{version}")
85
+ if v["status"] != status:
86
+ rprint(f"[dim]… {v['status']}[/dim]")
87
+ status = v["status"]
88
+ except KeyboardInterrupt:
89
+ rprint(f"[dim]stopped waiting; the build carries on. `gravity agents` shows {name}'s status.[/dim]")
90
+ return
91
+
92
+ if status == "ready":
93
+ rprint(f"[green]✓ {name}@{version} is ready[/green]")
94
+ elif status == "failed":
95
+ rprint(f"[red]✖ {name}@{version} failed:[/red] {v.get('status_reason') or 'no reason given'}")
96
+ raise typer.Exit(1)
97
+ else:
98
+ rprint(f"[dim]still {status} after {MAX_WAIT_SECONDS // 60} min; `gravity agents` shows its status.[/dim]")
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ import typer
12
+ from rich import print as rprint
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+ from rich.text import Text
17
+
18
+ from .. import project
19
+ from ..manifest import ManifestValidationError, load_manifest, render_issues
20
+ from ..timeline import EVENT_PREFIX, Timeline
21
+
22
+ RUNNER_SCRIPT = Path(__file__).parent.parent / "_runner_script.py"
23
+ # The builder's own tokens: never handed to agent or LLM-written code.
24
+ BUILDER_TOKENS = ("GRAVITY_DEV_TOKEN", "GRAVITY_API_TOKEN")
25
+
26
+
27
+ def gateway_urls() -> tuple[str, str]:
28
+ """(MCP url, host base). Defaults to `gravity-gateway serve`'s own port."""
29
+ url = os.environ.get("GRAVITY_GATEWAY_URL") or "http://127.0.0.1:8000/mcp"
30
+ return url, url.rstrip("/").removesuffix("/mcp")
31
+
32
+
33
+ def _mint_run_token(gateway_base: str, tools: list[str], approvals: list[str] = ()) -> dict:
34
+ """Dev token -> a run token scoped to the manifest's tools, as a hosted run gets."""
35
+ dev_token = os.environ.get("GRAVITY_DEV_TOKEN")
36
+ if not dev_token:
37
+ rprint("[red]✖[/red] missing: GRAVITY_DEV_TOKEN")
38
+ rprint(
39
+ "[dim]set it once in ~/.gravity/.env (loaded automatically, no export needed). "
40
+ "A local `gravity-gateway serve` prints one at startup.[/dim]"
41
+ )
42
+ raise typer.Exit(1)
43
+ try:
44
+ resp = httpx.post(
45
+ f"{gateway_base}/runs",
46
+ json={"tools": tools, "approvals": list(approvals)},
47
+ headers={"Authorization": f"Bearer {dev_token}"},
48
+ timeout=15,
49
+ )
50
+ except httpx.HTTPError as e:
51
+ rprint(f"[red]✖[/red] can't reach the gateway at {gateway_base}: {e}")
52
+ rprint("[dim]start one with `gravity-gateway serve`, or set GRAVITY_GATEWAY_URL.[/dim]")
53
+ raise typer.Exit(1) from e
54
+ if resp.status_code != 201:
55
+ try:
56
+ err = resp.json()["error"]
57
+ detail = f"{err['code']}: {err['message']}"
58
+ except (ValueError, KeyError, TypeError):
59
+ detail = resp.text
60
+ rprint(f"[red]✖[/red] the gateway refused this run — {detail}")
61
+ raise typer.Exit(1)
62
+ return resp.json() # token, run_id, ...
63
+
64
+
65
+ def _kill_tree(proc: subprocess.Popen) -> None:
66
+ """Kill the agent and its children: on Windows a venv's python.exe is a launcher."""
67
+ if os.name == "nt":
68
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True, check=False)
69
+ else:
70
+ proc.kill()
71
+ proc.wait()
72
+
73
+
74
+ def _recipients(arguments: dict) -> list[str]:
75
+ return [t for t in [arguments.get("recipient_email"), *(arguments.get("extra_recipients") or [])] if t]
76
+
77
+
78
+ def _summary(arguments: dict) -> str:
79
+ """One line on what a held call would do."""
80
+ to = _recipients(arguments)
81
+ if to:
82
+ subject = arguments.get("subject")
83
+ return f"→ {', '.join(to)}" + (f' · "{subject}"' if subject else "")
84
+ return json.dumps(arguments, default=str)[:80]
85
+
86
+
87
+ def _ask_approval(live, timeline: Timeline, ev: dict, gateway_base: str, run_id: str | None) -> None:
88
+ """Find the held call, show what it would do, send the builder's answer."""
89
+ headers = {"Authorization": f"Bearer {os.environ['GRAVITY_DEV_TOKEN']}"}
90
+ name = timeline._name(ev["tool"])
91
+ pending = None
92
+ for _ in range(60): # the call reaches the gateway a moment after it starts
93
+ try:
94
+ held = httpx.get(f"{gateway_base}/approvals", params={"run": run_id}, headers=headers, timeout=5).json()
95
+ except (httpx.HTTPError, ValueError):
96
+ held = []
97
+ pending = next((a for a in held if a.get("tool") == name), None)
98
+ if pending:
99
+ break
100
+ time.sleep(0.25)
101
+ if pending is None:
102
+ return # the call finished or failed on its own; its row will say how
103
+
104
+ args = pending.get("arguments") or {}
105
+ timeline.awaiting(ev.get("id"), _summary(args))
106
+ live.refresh()
107
+ live.stop()
108
+ timeline.console.line() # the live view ends mid-line
109
+ to = _recipients(args)
110
+ lines = [f"[bold]{name}[/bold] wants to run, and this agent's manifest requires approval for it.", ""]
111
+ if to:
112
+ body = str(args.get("body") or "")
113
+ lines.append(f"to {', '.join(to)}")
114
+ lines += [f"subject {args['subject']}"] if args.get("subject") else []
115
+ lines += [f"body {body[:300]}{'…' if len(body) > 300 else ''}"] if body else []
116
+ else:
117
+ lines.append(json.dumps(args, indent=2, default=str)[:600])
118
+ details = "\n".join(lines)
119
+ timeline.console.print(Panel(details, title="[yellow]approval needed", border_style="yellow", expand=False))
120
+ interrupted = False
121
+ unattended = os.environ.get("GRAVITY_UNATTENDED") == "1"
122
+ if sys.stdin.isatty() and not unattended:
123
+ try:
124
+ ok = Confirm.ask(" Approve?", default=False, console=timeline.console)
125
+ except (EOFError, KeyboardInterrupt):
126
+ # No answer is a no; Ctrl+C ends the run only after the gateway is told.
127
+ ok, interrupted = False, True
128
+ timeline.console.print("\n [dim]no answer — not approved[/dim]")
129
+ else:
130
+ ok = False
131
+ reason = "scheduled run, nobody to ask" if unattended else "no terminal to ask in"
132
+ timeline.console.print(f" [dim]{reason} — not approved. Run it interactively to approve.[/dim]")
133
+ try:
134
+ httpx.post(
135
+ f"{gateway_base}/approvals/{pending['id']}",
136
+ json={"decision": "approve" if ok else "reject"},
137
+ headers=headers,
138
+ timeout=10,
139
+ )
140
+ except httpx.HTTPError as e:
141
+ timeline.console.print(f" [red]✖[/red] couldn't send the decision: {e} — the gateway will time it out")
142
+ if interrupted:
143
+ raise KeyboardInterrupt
144
+ if ok:
145
+ timeline.approved(ev.get("id"))
146
+ timeline.continue_below(ev.get("id"))
147
+ live.start()
148
+
149
+
150
+ def run_command(
151
+ path: Path = typer.Argument(Path("."), help="Agent folder"),
152
+ input: list[str] = typer.Option(
153
+ [], "--input", help="An input as key=value, repeatable. key=@file.txt reads the value from a file"
154
+ ),
155
+ llm_url: str | None = typer.Option(
156
+ None, "--llm-url", help="Model endpoint to use instead of GRAVITY_LLM_URL"
157
+ ),
158
+ reply: str | None = typer.Option(
159
+ None, "--reply", help='Continue the last conversation of a chat agent, e.g. --reply "make it shorter"'
160
+ ),
161
+ ):
162
+ """Run your agent locally through the gateway. Needs GRAVITY_DEV_TOKEN in ~/.gravity/.env."""
163
+ project_dir = path.resolve()
164
+ try:
165
+ manifest = load_manifest(project_dir / "manifest.yaml")
166
+ except ManifestValidationError as e:
167
+ render_issues(e)
168
+ raise typer.Exit(1) from e
169
+
170
+ inputs = {}
171
+ for item in input:
172
+ if "=" not in item:
173
+ rprint(f"[red]✖[/red] --input {item!r} must be key=value")
174
+ raise typer.Exit(1)
175
+ k, v = item.split("=", 1)
176
+ if v.startswith("@"):
177
+ try:
178
+ v = Path(v[1:]).read_text(encoding="utf-8")
179
+ except OSError as e:
180
+ rprint(f"[red]✖[/red] --input {k}: can't read {v[1:]}: {e.strerror}")
181
+ raise typer.Exit(1) from e
182
+ inputs[k] = v
183
+
184
+ gateway_url, gateway_base = gateway_urls()
185
+ # A chat agent's last transcript, kept locally the way the platform keeps a thread.
186
+ thread_file = project_dir / ".gravity" / "thread.json"
187
+ history = None
188
+ if reply is not None:
189
+ if not thread_file.is_file():
190
+ rprint("[red]✖[/red] nothing to reply to: run it once first (only agents whose state has `messages` keep a thread)")
191
+ raise typer.Exit(1)
192
+ thread = json.loads(thread_file.read_text(encoding="utf-8"))
193
+ inputs = {**thread["inputs"], **inputs}
194
+ history = thread["messages"] + [{"role": "user", "content": reply}]
195
+
196
+ # Manifest defaults fill unset inputs, slot picks included.
197
+ inputs = {**manifest.default_inputs(), **inputs}
198
+ missing = [i.name for i in manifest.inputs if i.required and inputs.get(i.name) in (None, "")]
199
+ if missing:
200
+ rprint(f"[red]✖[/red] missing required input(s): {', '.join(missing)} — pass --input {missing[0]}=...")
201
+ raise typer.Exit(1)
202
+ try:
203
+ tools = manifest.tools_for(inputs)
204
+ except ValueError as e:
205
+ rprint(f"[red]✖[/red] --input {e}")
206
+ raise typer.Exit(1) from e
207
+ gated = [t for t in manifest.approvals.required_for if t in tools]
208
+ minted = _mint_run_token(gateway_base, tools, gated)
209
+ run_token, run_id = minted["token"], minted.get("run_id")
210
+
211
+ env = {
212
+ # The agent gets the run token only, never BUILDER_TOKENS.
213
+ **{k: v for k, v in os.environ.items() if k not in BUILDER_TOKENS},
214
+ "GRAVITY_GATEWAY_URL": gateway_url,
215
+ "GRAVITY_RUN_TOKEN": run_token,
216
+ # Models share the tool gateway's host: .../mcp -> .../v1.
217
+ "GRAVITY_LLM_URL": llm_url or os.environ.get("GRAVITY_LLM_URL") or f"{gateway_base}/v1",
218
+ }
219
+
220
+ # Memory across runs (manifest `state`), kept locally as the platform keeps it.
221
+ memory_file = project_dir / ".gravity" / "memory.json"
222
+ payload = {"inputs": inputs, **({"messages": history} if history else {})}
223
+ if manifest.state is not None:
224
+ payload["memory"] = json.loads(memory_file.read_text(encoding="utf-8")) if memory_file.is_file() else {}
225
+
226
+ # Through a file, not argv: a pasted dataset overflows Windows' 32 KB command line.
227
+ payload_file = project_dir / ".gravity" / "payload.json"
228
+ payload_file.parent.mkdir(exist_ok=True)
229
+ payload_file.write_text(json.dumps(payload), encoding="utf-8")
230
+
231
+ entry_file, entry_object = manifest.entrypoint_parts()
232
+ python = project.find_python(project_dir)
233
+
234
+ env["PYTHONUTF8"] = "1" # the runner's events carry non-ASCII text on Windows too
235
+ timeline = Timeline(
236
+ Console(),
237
+ agent=f"{manifest.name}@{manifest.version}",
238
+ run_token=run_token,
239
+ tools=tools,
240
+ )
241
+ proc = subprocess.Popen(
242
+ [python, str(RUNNER_SCRIPT), entry_file, entry_object, str(payload_file)],
243
+ cwd=project_dir,
244
+ env=env,
245
+ stdout=subprocess.PIPE,
246
+ stderr=subprocess.STDOUT,
247
+ text=True,
248
+ encoding="utf-8",
249
+ errors="replace",
250
+ )
251
+ try:
252
+ with timeline.live() as live:
253
+ for line in proc.stdout:
254
+ line = line.rstrip("\n")
255
+ if line.startswith(EVENT_PREFIX):
256
+ ev = json.loads(line[len(EVENT_PREFIX):])
257
+ timeline.handle(ev)
258
+ if ev.get("type") == "tool.started" and timeline._name(ev["tool"]) in gated:
259
+ _ask_approval(live, timeline, ev, gateway_base, run_id)
260
+ elif line.strip():
261
+ # The agent's own output, dimmed under the timeline.
262
+ live.console.print(Text(line, style="dim"))
263
+ except BaseException:
264
+ # Leaving early: the agent must not carry on unobserved.
265
+ _kill_tree(proc)
266
+ raise
267
+ proc.wait()
268
+ outcome = timeline.outcome or {}
269
+ if outcome.get("messages"):
270
+ thread_file.parent.mkdir(exist_ok=True)
271
+ thread_file.write_text(json.dumps({"inputs": inputs, "messages": outcome["messages"]}), encoding="utf-8")
272
+ if manifest.state is not None and outcome.get("type") == "run.finished" and outcome.get("memory") is not None:
273
+ blob = json.dumps(outcome["memory"])
274
+ if len(blob.encode("utf-8")) > manifest.state.max_kb * 1024:
275
+ # Over the cap: keep the old memory rather than a truncated one.
276
+ rprint(f"[yellow]⚠[/yellow] memory is over state.max_kb ({manifest.state.max_kb} KB) — not saved")
277
+ else:
278
+ memory_file.parent.mkdir(exist_ok=True)
279
+ memory_file.write_text(blob, encoding="utf-8")
280
+ code = timeline.finish()
281
+ raise typer.Exit(code or proc.returncode)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich import print as rprint
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from .. import api
9
+
10
+ console = Console()
11
+
12
+
13
+ def agents_command():
14
+ """Your agents in the marketplace, with review status."""
15
+ agents = api.get("/api/cli/agents")
16
+ table = Table()
17
+ for col in ("name", "version", "status", "updated_at"):
18
+ table.add_column(col)
19
+ for a in agents:
20
+ table.add_row(a.get("name", ""), str(a.get("version", "")), a.get("status", ""), a.get("updated_at", ""))
21
+ console.print(table)
22
+
23
+
24
+ def runs_command(
25
+ agent_name: str = typer.Argument(..., help="Name of the agent"),
26
+ ):
27
+ """Recent runs with status and cost."""
28
+ runs = api.get(f"/api/cli/agents/{agent_name}/runs")
29
+ if not runs:
30
+ rprint(f"[dim]no runs yet for {agent_name}.[/dim]")
31
+ return
32
+ table = Table()
33
+ for col in ("id", "status", "started_at", "cost"):
34
+ table.add_column(col)
35
+ for r in runs:
36
+ table.add_row(r.get("id", ""), r.get("status", ""), r.get("started_at", ""), str(r.get("cost", "")))
37
+ console.print(table)
38
+
39
+
40
+ def logs_command(
41
+ run_id: str = typer.Argument(..., help="Run id, as shown by gravity runs"),
42
+ ):
43
+ """The full timeline + your own stdout for one run."""
44
+ logs = api.get(f"/api/cli/runs/{run_id}/logs")
45
+ if logs.get("message"):
46
+ rprint(f"[dim]{logs['message']}[/dim]")
47
+ return
48
+ for line in logs.get("lines", []):
49
+ rprint(line)
@@ -0,0 +1,74 @@
1
+ """`gravity schedule`: each tick is an ordinary `gravity run` with nobody at the terminal,
2
+ so gated calls are not approved."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import time
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from gravity_schema.cron import next_run
13
+ from gravity_schema.manifest import ScheduleSpec
14
+ from rich import print as rprint
15
+
16
+ from ..manifest import ManifestValidationError, load_manifest, render_issues
17
+ from .run import run_command
18
+
19
+
20
+ def schedule_command(
21
+ path: Path = typer.Argument(Path("."), help="Agent folder"),
22
+ input: list[str] = typer.Option([], "--input", help="An input as key=value, repeatable. The same inputs every run"),
23
+ cron: str | None = typer.Option(None, "--cron", help="Use this cron schedule instead of the manifest's, if it allows changes"),
24
+ now: bool = typer.Option(False, "--now", help="Run once right away, then keep to the schedule"),
25
+ ):
26
+ """Run the agent on its schedule, locally, until Ctrl+C."""
27
+ project_dir = path.resolve()
28
+ try:
29
+ manifest = load_manifest(project_dir / "manifest.yaml")
30
+ except ManifestValidationError as e:
31
+ render_issues(e)
32
+ raise typer.Exit(1) from e
33
+
34
+ declared = manifest.triggers.schedule
35
+ if cron and declared and not declared.consumer_can_change:
36
+ rprint("[red]✖[/red] this agent's schedule can't be changed (consumer_can_change: false)")
37
+ raise typer.Exit(1)
38
+ expr = cron or (declared.cron if declared else None)
39
+ if not expr:
40
+ rprint("[red]✖[/red] no triggers.schedule in manifest.yaml — add one, or pass --cron '0 9 * * 1-5'")
41
+ raise typer.Exit(1)
42
+ try:
43
+ ScheduleSpec(cron=expr) # same validation as the manifest
44
+ except Exception as e:
45
+ rprint(f"[red]✖[/red] {e}")
46
+ raise typer.Exit(1) from e
47
+
48
+ # A scheduled run has nobody to ask; `gravity run` then treats approvals as "no".
49
+ os.environ["GRAVITY_UNATTENDED"] = "1"
50
+ memory = "keeps memory between runs" if manifest.state else "no memory between runs (no `state` in the manifest)"
51
+ rprint(f"[bold]{manifest.name}[/bold] on [cyan]{expr}[/cyan] · {memory} · Ctrl+C to stop")
52
+
53
+ runs = 0
54
+ try:
55
+ if now:
56
+ _tick(project_dir, input, runs)
57
+ runs += 1
58
+ while True:
59
+ at = next_run(expr, datetime.now())
60
+ rprint(f"[dim]next run {at:%a %d %b %H:%M}[/dim]")
61
+ time.sleep(max(0.0, (at - datetime.now()).total_seconds()))
62
+ _tick(project_dir, input, runs)
63
+ runs += 1
64
+ except KeyboardInterrupt:
65
+ rprint(f"\n[dim]stopped after {runs} run(s)[/dim]")
66
+
67
+
68
+ def _tick(project_dir: Path, inputs: list[str], done: int) -> None:
69
+ rprint(f"\n[bold]scheduled run {done + 1}[/bold] · {datetime.now():%a %d %b %H:%M}")
70
+ try:
71
+ run_command(project_dir, input=inputs, llm_url=None, reply=None)
72
+ except typer.Exit as e:
73
+ if e.exit_code:
74
+ rprint(f"[yellow]⚠[/yellow] run {done + 1} failed (exit {e.exit_code}); the schedule carries on")
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from .. import api
8
+
9
+ console = Console()
10
+
11
+
12
+ def tools_command(
13
+ search: str = typer.Argument(None, help="Only show tools whose name contains this"),
14
+ ):
15
+ """The tool catalog: what you can put in permissions.tools."""
16
+ catalog = api.get("/api/cli/tools")
17
+
18
+ if search:
19
+ catalog = [t for t in catalog if search.lower() in t["name"].lower()]
20
+
21
+ table = Table()
22
+ table.add_column("name")
23
+ table.add_column("description")
24
+ table.add_column("scopes")
25
+ for t in catalog:
26
+ table.add_row(t["name"], t.get("description", ""), ", ".join(t.get("scopes", [])))
27
+ console.print(table)