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.
- gravity_cli/__init__.py +11 -0
- gravity_cli/__main__.py +4 -0
- gravity_cli/_runner_script.py +195 -0
- gravity_cli/api.py +88 -0
- gravity_cli/cli.py +124 -0
- gravity_cli/commands/__init__.py +0 -0
- gravity_cli/commands/adapt.py +155 -0
- gravity_cli/commands/build.py +83 -0
- gravity_cli/commands/init.py +78 -0
- gravity_cli/commands/login.py +58 -0
- gravity_cli/commands/push.py +98 -0
- gravity_cli/commands/run.py +281 -0
- gravity_cli/commands/runs.py +49 -0
- gravity_cli/commands/schedule.py +74 -0
- gravity_cli/commands/tools.py +27 -0
- gravity_cli/commands/validate.py +195 -0
- gravity_cli/config.py +91 -0
- gravity_cli/manifest.py +19 -0
- gravity_cli/project.py +42 -0
- gravity_cli/templates/chat/.gitignore +6 -0
- gravity_cli/templates/chat/manifest.yaml +28 -0
- gravity_cli/templates/chat/requirements.txt +3 -0
- gravity_cli/templates/chat/src/agent.py +80 -0
- gravity_cli/templates/deepagent/.gitignore +6 -0
- gravity_cli/templates/deepagent/manifest.yaml +28 -0
- gravity_cli/templates/deepagent/requirements.txt +4 -0
- gravity_cli/templates/deepagent/src/agent.py +54 -0
- gravity_cli/templates/minimal/.gitignore +6 -0
- gravity_cli/templates/minimal/manifest.yaml +30 -0
- gravity_cli/templates/minimal/requirements.txt +3 -0
- gravity_cli/templates/minimal/src/agent.py +58 -0
- gravity_cli/templates/structured/.gitignore +6 -0
- gravity_cli/templates/structured/manifest.yaml +33 -0
- gravity_cli/templates/structured/requirements.txt +3 -0
- gravity_cli/templates/structured/src/agent.py +15 -0
- gravity_cli/templates/structured/src/gateway.py +26 -0
- gravity_cli/templates/structured/src/nodes/agent.py +20 -0
- gravity_cli/templates/structured/src/prompts.py +6 -0
- gravity_cli/templates/structured/src/state.py +10 -0
- gravity_cli/timeline.py +254 -0
- gravity_cli-0.1.0.dist-info/METADATA +494 -0
- gravity_cli-0.1.0.dist-info/RECORD +46 -0
- gravity_cli-0.1.0.dist-info/WHEEL +4 -0
- gravity_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gravity_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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)
|