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,33 @@
1
+ contract: v1
2
+ name: {{name}}
3
+ version: 1.0.0
4
+ description: "a structured langgraph agent, scaffolded by gravity init"
5
+
6
+ runtime:
7
+ framework: langgraph
8
+ python: "3.13"
9
+ entrypoint: src/agent.py:graph
10
+ timeout_seconds: 300
11
+
12
+ dependencies:
13
+ lockfile: requirements.lock
14
+
15
+ inputs:
16
+ - name: query
17
+ type: text
18
+ label: "What should the agent do?"
19
+ required: true
20
+ - name: tone
21
+ type: static-dropdown
22
+ label: "Reply tone"
23
+ options: [formal, friendly, brief]
24
+ default: friendly
25
+
26
+ permissions:
27
+ tools:
28
+ - example.echo
29
+ models:
30
+ tier: standard
31
+
32
+ resources:
33
+ memory_mb: 512
@@ -0,0 +1,3 @@
1
+ langgraph>=1.0,<2.0
2
+ langchain-openai>=1.0,<2.0
3
+ langchain-mcp-adapters>=0.3,<0.4
@@ -0,0 +1,15 @@
1
+ # src/agent.py — assembles the graph from src/nodes/, state.py and prompts.py.
2
+ from langgraph.graph import END, StateGraph
3
+ from nodes.agent import build_node
4
+ from state import State
5
+
6
+
7
+ # ← the object manifest.runtime.entrypoint points at. An async factory the
8
+ # runner calls fresh for every run — never build the graph at import time.
9
+ async def graph():
10
+ run_agent = await build_node()
11
+ workflow = StateGraph(State)
12
+ workflow.add_node("agent", run_agent)
13
+ workflow.set_entry_point("agent")
14
+ workflow.add_edge("agent", END)
15
+ return workflow.compile()
@@ -0,0 +1,26 @@
1
+ """Model and tool clients in one place. Functions, not module-level clients: env vars are set per run."""
2
+ import os
3
+
4
+ from langchain_mcp_adapters.client import MultiServerMCPClient
5
+ from langchain_openai import ChatOpenAI
6
+
7
+
8
+ def make_model() -> ChatOpenAI:
9
+ # The run token is the api key; the llm gateway swaps it for the real one.
10
+ return ChatOpenAI(
11
+ model="openai/gpt-6-luna",
12
+ base_url=os.environ["GRAVITY_LLM_URL"],
13
+ api_key=os.environ["GRAVITY_RUN_TOKEN"],
14
+ )
15
+
16
+
17
+ def make_mcp_client() -> MultiServerMCPClient:
18
+ return MultiServerMCPClient(
19
+ {
20
+ "gravity": {
21
+ "transport": "streamable_http",
22
+ "url": os.environ["GRAVITY_GATEWAY_URL"],
23
+ "headers": {"Authorization": f"Bearer {os.environ['GRAVITY_RUN_TOKEN']}"},
24
+ }
25
+ }
26
+ )
@@ -0,0 +1,20 @@
1
+ """The one node this scaffold ships with. Imports are absolute from src/ (AGENT_STANDARD.md §4)."""
2
+ from gateway import make_mcp_client, make_model
3
+ from langgraph.prebuilt import create_react_agent
4
+ from prompts import SYSTEM_PROMPT
5
+
6
+
7
+ async def build_node():
8
+ tools = await make_mcp_client().get_tools()
9
+ react = create_react_agent(make_model(), tools, prompt=SYSTEM_PROMPT)
10
+
11
+ async def run_agent(state):
12
+ query = state["inputs"].get("query", "")
13
+ tone = state["inputs"].get("tone", "friendly")
14
+ result = await react.ainvoke(
15
+ {"messages": [{"role": "user", "content": f"[{tone} tone] {query}"}]}
16
+ )
17
+ # state["result"] is what the platform returns to the consumer.
18
+ return {"result": result["messages"][-1].content}
19
+
20
+ return run_agent
@@ -0,0 +1,6 @@
1
+ """Prompts, as their own file — grows naturally as an agent's nodes grow."""
2
+
3
+ SYSTEM_PROMPT = (
4
+ "you help with whatever the user's query asks, using the tools "
5
+ "available to you. match the requested tone."
6
+ )
@@ -0,0 +1,10 @@
1
+ """Typed state, in its own module — split out once an agent has more than
2
+ one node worth naming."""
3
+ from typing import TypedDict
4
+
5
+
6
+ class State(TypedDict):
7
+ inputs: dict
8
+ # No `messages` here on purpose: this agent answers one question per run.
9
+ # Declaring it is how an agent opts into conversation — see the `chat` template.
10
+ result: str # set by the last node; the platform returns it
@@ -0,0 +1,254 @@
1
+ """`gravity run`'s live view: the runner's JSON events as a timeline, then a result card."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from rich.console import Console, Group
11
+ from rich.live import Live
12
+ from rich.panel import Panel
13
+ from rich.spinner import Spinner
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ EVENT_PREFIX = "@@gravity " # must match _runner_script.EVENT_PREFIX
18
+
19
+
20
+ @dataclass
21
+ class Row:
22
+ kind: str # step | model | tool
23
+ label: str
24
+ detail: str = ""
25
+ ms: int | None = None
26
+ status: str = "running" # running | awaiting | ok | refused | failed
27
+ key: str | None = None # the tool call's run id, so its end updates its row
28
+
29
+
30
+ def _seconds(ms: int | None) -> str:
31
+ return f"{ms / 1000:.1f}s" if ms is not None else ""
32
+
33
+
34
+ def _pick(data: Any, keys=("identifier", "url", "id")) -> str | None:
35
+ """The most useful reference in a tool's output (an issue key, a URL, an id)."""
36
+ if isinstance(data, dict):
37
+ for k in keys:
38
+ if isinstance(data.get(k), str | int):
39
+ return str(data[k])
40
+ for v in data.values():
41
+ if found := _pick(v, keys):
42
+ return found
43
+ elif isinstance(data, list):
44
+ for v in data:
45
+ if found := _pick(v, keys):
46
+ return found
47
+ return None
48
+
49
+
50
+ def _tool_detail(output: str) -> str:
51
+ try:
52
+ data = json.loads(output)
53
+ except (json.JSONDecodeError, TypeError):
54
+ return ""
55
+ if isinstance(data, dict) and data.get("successful") is False:
56
+ return f"error: {data.get('error')}"
57
+ # A listing reads as how many it found, not as its first item's id.
58
+ records = _first_records(data)
59
+ if records is not None and len(records) != 1:
60
+ return f"{len(records)} found"
61
+ return _pick(data) or ""
62
+
63
+
64
+ def _first_records(data: Any) -> list | None:
65
+ """The first list of objects in a payload, if it has one."""
66
+ if isinstance(data, list):
67
+ return data if data and all(isinstance(i, dict) for i in data) else None
68
+ if isinstance(data, dict):
69
+ for v in data.values():
70
+ if (found := _first_records(v)) is not None:
71
+ return found
72
+ return None
73
+
74
+
75
+ class Timeline:
76
+ def __init__(self, console: Console, *, agent: str, run_token: str, tools: list[str]):
77
+ self.console = console
78
+ self.rows: list[Row] = []
79
+ self.gravity_name = {t.replace(".", "_"): t for t in tools}
80
+ self.outcome: dict | None = None
81
+ self.shown_from = 0 # rows above this are already on screen, above a prompt
82
+ short = f"{run_token[:4]}…{run_token[-4:]}" if len(run_token) > 12 else run_token
83
+ console.print()
84
+ console.print(
85
+ f" [bold]{agent}[/bold] [dim]· run {short} · {len(tools)} "
86
+ f"tool{'s' if len(tools) != 1 else ''} declared[/dim]\n"
87
+ )
88
+
89
+ # events
90
+
91
+ def handle(self, ev: dict) -> None:
92
+ t = ev.get("type")
93
+ if t == "step.started":
94
+ self.rows.append(Row("step", ev["node"]))
95
+ elif t == "step.finished":
96
+ for row in reversed(self.rows):
97
+ if row.kind == "step" and row.label == ev["node"] and row.status == "running":
98
+ row.status, row.ms = "ok", ev.get("ms")
99
+ break
100
+ elif t == "model.called":
101
+ self.rows.append(Row("model", ev.get("model") or "model", ms=ev.get("ms"), status="ok"))
102
+ elif t == "tool.started":
103
+ self.rows.append(Row("tool", self._name(ev["tool"]), key=ev.get("id")))
104
+ elif t in ("tool.called", "tool.failed"):
105
+ label, detail, status = self._outcome(ev)
106
+ row = self._row(ev.get("id"))
107
+ if row is None:
108
+ self.rows.append(Row("tool", label, detail, ev.get("ms"), status))
109
+ else:
110
+ row.label, row.detail, row.ms, row.status = label, detail, ev.get("ms"), status
111
+ elif t in ("run.finished", "run.failed"):
112
+ self.outcome = ev
113
+ for row in self.rows:
114
+ if row.status == "running":
115
+ row.status = "ok" if t == "run.finished" else "failed"
116
+
117
+ def _name(self, exposed: str) -> str:
118
+ return self.gravity_name.get(exposed, exposed)
119
+
120
+ def _row(self, key: str | None) -> Row | None:
121
+ return next((r for r in self.rows if key and r.key == key), None)
122
+
123
+ def _outcome(self, ev: dict) -> tuple[str, str, str]:
124
+ """(label, detail, status) for a finished call; a refusal may arrive as an exception or as output."""
125
+ name = self._name(ev["tool"])
126
+ text = ev.get("error") if ev["type"] == "tool.failed" else ev.get("output", "")
127
+ text = text or ""
128
+ if "TOOL_NOT_DECLARED" in text:
129
+ # Never declared, so not in our name map; the refusal names it.
130
+ named = re.search(r"'([a-z0-9_.]+)' is not in", text)
131
+ return (named.group(1) if named else name, "refused — not in permissions.tools", "refused")
132
+ if "APPROVAL_REJECTED" in text:
133
+ return name, "not approved — did not run", "refused"
134
+ if "APPROVAL_TIMEOUT" in text:
135
+ return name, "approval timed out — did not run", "refused"
136
+ failed = re.search(r"\b(TOOL_UNAVAILABLE|NEEDS_RECONNECT|RUN_NOT_ACTIVE|UNAUTHORIZED)\b", text)
137
+ if failed:
138
+ # A gateway error returned as output, not raised: still a failure.
139
+ return name, text[failed.start():][:80], "failed"
140
+ if ev["type"] == "tool.failed":
141
+ return name, text[:80], "failed"
142
+ detail = _tool_detail(text)
143
+ return name, detail, "failed" if detail.startswith("error:") else "ok"
144
+
145
+ def awaiting(self, key: str | None, summary: str) -> None:
146
+ """A call the gateway is holding for approval."""
147
+ row = self._row(key)
148
+ if row is not None:
149
+ row.status, row.detail = "awaiting", summary
150
+
151
+ def continue_below(self, key: str | None) -> None:
152
+ """After a prompt, redraw from the paused step, not the whole run."""
153
+ row = self._row(key)
154
+ at = self.rows.index(row) if row is not None else len(self.rows)
155
+ steps = [i for i, r in enumerate(self.rows[:at]) if r.kind == "step"]
156
+ self.shown_from = steps[-1] if steps else at
157
+
158
+ def approved(self, key: str | None) -> None:
159
+ row = self._row(key)
160
+ if row is not None:
161
+ row.status, row.detail = "running", "approved — running"
162
+
163
+ # drawing
164
+
165
+ def render(self) -> Table:
166
+ grid = Table.grid(padding=(0, 2))
167
+ grid.add_column(width=4)
168
+ grid.add_column(min_width=24)
169
+ grid.add_column(min_width=18, style="cyan")
170
+ grid.add_column(justify="right", style="dim")
171
+ for row in self.rows[self.shown_from:]:
172
+ indent = "" if row.kind == "step" else " "
173
+ if row.kind == "step":
174
+ icon = Spinner("dots", style="yellow") if row.status == "running" else Text("●", "bold blue")
175
+ label = Text(row.label, "bold")
176
+ elif row.kind == "model":
177
+ icon, label = Text(indent + "◆", "magenta"), Text(row.label, "magenta")
178
+ else:
179
+ icon = {
180
+ "running": Spinner("dots", style="yellow"),
181
+ "awaiting": Text(indent + "⏸", "bold yellow"),
182
+ "ok": Text(indent + "✓", "bold green"),
183
+ "refused": Text(indent + "✗", "bold red"),
184
+ "failed": Text(indent + "✗", "bold red"),
185
+ }[row.status]
186
+ label = Text(row.label, "red" if row.status in ("refused", "failed") else "")
187
+ detail = Text(
188
+ row.detail,
189
+ {"refused": "red", "failed": "red", "awaiting": "yellow"}.get(row.status, "cyan"),
190
+ )
191
+ grid.add_row(icon, label, detail, _seconds(row.ms))
192
+ return grid
193
+
194
+ def live(self) -> Live:
195
+ return Live(self.render(), console=self.console, refresh_per_second=12, get_renderable=self.render)
196
+
197
+ def finish(self) -> int:
198
+ """Print the result card and the final line; returns the exit code."""
199
+ out = self.outcome or {"type": "run.failed", "error": "the runner exited without a result"}
200
+ # The live view ends mid-line; the first newline closes it.
201
+ self.console.line(2)
202
+ if out["type"] == "run.failed":
203
+ where = "loading the agent" if out.get("stage") == "load" else "the run"
204
+ self.console.print(
205
+ Panel(Text(out.get("error", ""), "red"), title=f"[red]failed in {where}", border_style="red")
206
+ )
207
+ return 1
208
+ if not out.get("has_result"):
209
+ self.console.print(
210
+ " [yellow]⚠[/yellow] the final state has no [bold]result[/bold] — a consumer would "
211
+ "get nothing (AGENT_STANDARD.md §3)"
212
+ )
213
+ else:
214
+ self.console.print(Panel(_render_value(out.get("result")), title="result", border_style="green", expand=False))
215
+ self.console.print(f" [green]✔[/green] done in {_seconds(out.get('ms'))}\n")
216
+ return 0
217
+
218
+
219
+ def _render_value(value: Any):
220
+ """A result as prose, a key/value list, or a table of records."""
221
+ if isinstance(value, str) or value is None:
222
+ return Text(str(value or ""))
223
+ if isinstance(value, list):
224
+ return _render_list(value)
225
+ if not isinstance(value, dict):
226
+ return Text(json.dumps(value, default=str))
227
+ parts = []
228
+ for key, v in value.items():
229
+ if key == "source":
230
+ continue
231
+ if isinstance(v, list) and v:
232
+ parts += [Text(key, "bold"), _render_list(v)]
233
+ elif isinstance(v, dict):
234
+ parts.append(Text.assemble((f"{key} ", "bold"), json.dumps(v, default=str)[:200]))
235
+ elif v not in (None, "", []):
236
+ parts.append(Text.assemble((f"{key} ", "bold"), str(v)))
237
+ return Group(*parts)
238
+
239
+
240
+ def _render_list(items: list):
241
+ if all(isinstance(i, dict) for i in items):
242
+ columns: list[str] = []
243
+ for item in items:
244
+ for k, v in item.items():
245
+ # URLs don't fit a terminal table; the id beside them does.
246
+ if k not in columns and k != "url" and not isinstance(v, dict | list):
247
+ columns.append(k)
248
+ table = Table(box=None, pad_edge=False, show_edge=False, header_style="dim")
249
+ for c in columns[:6]:
250
+ table.add_column(c)
251
+ for item in items:
252
+ table.add_row(*(str(item.get(c) if item.get(c) is not None else "") for c in columns[:6]))
253
+ return table
254
+ return Text("\n".join(f"• {i}" for i in items))