grains-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {grains_cli-0.1.0 → grains_cli-0.2.0}/PKG-INFO +3 -1
- {grains_cli-0.1.0 → grains_cli-0.2.0}/README.md +2 -0
- grains_cli-0.2.0/grains_cli/apiclient.py +81 -0
- grains_cli-0.2.0/grains_cli/deploy.py +80 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/dev.py +51 -8
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/init.py +7 -3
- grains_cli-0.2.0/grains_cli/login.py +173 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/main.py +35 -1
- grains_cli-0.2.0/grains_cli/ship.py +79 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/templates.py +15 -2
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/PKG-INFO +3 -1
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/SOURCES.txt +6 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/entry_points.txt +1 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/pyproject.toml +2 -1
- grains_cli-0.2.0/tests/test_deploy.py +79 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_dev.py +20 -0
- grains_cli-0.2.0/tests/test_init_node.py +88 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/LICENSE +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/__init__.py +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/pay.py +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/verify.py +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/dependency_links.txt +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/requires.txt +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/top_level.txt +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/setup.cfg +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_init.py +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_pay.py +0 -0
- {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grains-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line tools for Grains: build, run, deploy, and pay AI agents.
|
|
5
5
|
Author: Grains
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -23,6 +23,8 @@ Requires-Dist: httpx
|
|
|
23
23
|
Requires-Dist: eth-account>=0.11
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
|
+
<p align="center"><img src="https://grains.run/brand-icon.png" width="96" alt="Grains"></p>
|
|
27
|
+
|
|
26
28
|
# grains-cli
|
|
27
29
|
|
|
28
30
|
Command-line tools for [Grains](https://grains.run) — build, run, deploy, and
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Minimal Grains control-plane client for the CLI (deploy/logs/agents).
|
|
2
|
+
|
|
3
|
+
Authenticated by a deploy token (--token or GRAINS_DEPLOY_TOKEN). Kept
|
|
4
|
+
self-contained so grains-cli doesn't depend on grains-mcp.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
DEFAULT_API = "https://api.grains.run"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GrainsAPIError(Exception):
|
|
19
|
+
def __init__(self, status: int, body):
|
|
20
|
+
super().__init__(f"HTTP {status}: {body}")
|
|
21
|
+
self.status = status
|
|
22
|
+
self.body = body
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_api(explicit: str | None) -> str:
|
|
26
|
+
return (explicit or os.environ.get("GRAINS_API_URL") or DEFAULT_API).rstrip("/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_token(explicit: str | None) -> str | None:
|
|
30
|
+
if explicit or os.environ.get("GRAINS_DEPLOY_TOKEN"):
|
|
31
|
+
return explicit or os.environ.get("GRAINS_DEPLOY_TOKEN")
|
|
32
|
+
from .login import stored_token # lazy: avoid import cycle
|
|
33
|
+
return stored_token(resolve_api(None))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def zip_project(path: Path) -> bytes:
|
|
37
|
+
"""Zip an agent project: grains.toml + grains_app.* + any other files,
|
|
38
|
+
skipping caches/VCS/virtualenvs."""
|
|
39
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "dist", "build",
|
|
40
|
+
".pytest_cache", ".ruff_cache", ".egg-info"}
|
|
41
|
+
buf = io.BytesIO()
|
|
42
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
43
|
+
for f in sorted(path.rglob("*")):
|
|
44
|
+
if f.is_dir():
|
|
45
|
+
continue
|
|
46
|
+
if any(part in skip_dirs or part.endswith(".egg-info") for part in f.relative_to(path).parts):
|
|
47
|
+
continue
|
|
48
|
+
zf.write(f, f.relative_to(path).as_posix())
|
|
49
|
+
return buf.getvalue()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GrainsClient:
|
|
53
|
+
def __init__(self, api: str, token: str, http: httpx.Client | None = None):
|
|
54
|
+
self.api = api.rstrip("/")
|
|
55
|
+
self.token = token
|
|
56
|
+
self.http = http if http is not None else httpx.Client(timeout=120.0)
|
|
57
|
+
|
|
58
|
+
def _req(self, method: str, path: str, **kw):
|
|
59
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
60
|
+
headers.update(kw.pop("headers", None) or {})
|
|
61
|
+
r = self.http.request(method, self.api + path, headers=headers, **kw)
|
|
62
|
+
if r.status_code >= 400:
|
|
63
|
+
try:
|
|
64
|
+
body = r.json()
|
|
65
|
+
except ValueError:
|
|
66
|
+
body = r.text
|
|
67
|
+
raise GrainsAPIError(r.status_code, body)
|
|
68
|
+
return {} if r.status_code == 204 or not r.content else r.json()
|
|
69
|
+
|
|
70
|
+
def create_agent(self, name: str) -> dict:
|
|
71
|
+
return self._req("POST", "/v1/agents", json={"name": name})
|
|
72
|
+
|
|
73
|
+
def deploy(self, name: str, zip_bytes: bytes) -> dict:
|
|
74
|
+
return self._req("POST", f"/v1/agents/{name}/deployments",
|
|
75
|
+
content=zip_bytes, headers={"Content-Type": "application/zip"})
|
|
76
|
+
|
|
77
|
+
def list_agents(self) -> dict:
|
|
78
|
+
return self._req("GET", "/v1/agents")
|
|
79
|
+
|
|
80
|
+
def logs(self, name: str, limit: int = 50) -> dict:
|
|
81
|
+
return self._req("GET", f"/v1/agents/{name}/logs?limit={limit}")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""`grains deploy` / `grains logs` / `grains agents` — CLI control-plane commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .apiclient import GrainsAPIError, GrainsClient, resolve_api, resolve_token, zip_project
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _client_or_exit(args) -> GrainsClient:
|
|
13
|
+
token = resolve_token(getattr(args, "token", None))
|
|
14
|
+
if not token:
|
|
15
|
+
print("error: provide --token or set GRAINS_DEPLOY_TOKEN "
|
|
16
|
+
"(get one at https://api.grains.run/welcome)", file=sys.stderr)
|
|
17
|
+
raise SystemExit(2)
|
|
18
|
+
return GrainsClient(resolve_api(getattr(args, "api", None)), token)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _agent_name(path: Path) -> str:
|
|
22
|
+
toml_path = path / "grains.toml"
|
|
23
|
+
if not toml_path.exists():
|
|
24
|
+
print(f"error: no grains.toml in {path} (run `grains init` first)", file=sys.stderr)
|
|
25
|
+
raise SystemExit(2)
|
|
26
|
+
return tomllib.loads(toml_path.read_text())["agent"]["name"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_deploy(args: argparse.Namespace) -> int:
|
|
30
|
+
path = Path(args.path).resolve()
|
|
31
|
+
name = _agent_name(path)
|
|
32
|
+
client = _client_or_exit(args)
|
|
33
|
+
try:
|
|
34
|
+
try:
|
|
35
|
+
client.create_agent(name)
|
|
36
|
+
print(f"created agent {name}")
|
|
37
|
+
except GrainsAPIError as e:
|
|
38
|
+
if e.status != 409: # already exists is fine
|
|
39
|
+
raise
|
|
40
|
+
result = client.deploy(name, zip_project(path))
|
|
41
|
+
except GrainsAPIError as e:
|
|
42
|
+
print(f"error: {e}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
dep = result.get("deployment", result)
|
|
45
|
+
status = dep.get("status")
|
|
46
|
+
print(f"deployed {name}: {status}")
|
|
47
|
+
if dep.get("error"):
|
|
48
|
+
print(f" error: {dep['error']}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
return 0 if status == "live" else 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_logs(args: argparse.Namespace) -> int:
|
|
54
|
+
client = _client_or_exit(args)
|
|
55
|
+
try:
|
|
56
|
+
events = client.logs(args.name, args.limit).get("events", [])
|
|
57
|
+
except GrainsAPIError as e:
|
|
58
|
+
print(f"error: {e}", file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
if not events:
|
|
61
|
+
print("(no logs)")
|
|
62
|
+
for e in events:
|
|
63
|
+
print(e.get("message", ""))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_agents(args: argparse.Namespace) -> int:
|
|
68
|
+
client = _client_or_exit(args)
|
|
69
|
+
try:
|
|
70
|
+
agents = client.list_agents().get("agents", [])
|
|
71
|
+
except GrainsAPIError as e:
|
|
72
|
+
print(f"error: {e}", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
if not agents:
|
|
75
|
+
print("(no agents)")
|
|
76
|
+
for a in agents:
|
|
77
|
+
price = f" {a['price_value']} {a.get('price_currency', 'USDC')}" if a.get("price_value") else ""
|
|
78
|
+
vis = "public" if a.get("public") else "private"
|
|
79
|
+
print(f"{a['name']:<24} {a.get('status', ''):<8} {vis}{price}")
|
|
80
|
+
return 0
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import importlib.util
|
|
6
6
|
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
7
9
|
import sys
|
|
8
10
|
import threading
|
|
9
11
|
import tomllib
|
|
@@ -50,6 +52,36 @@ def _load_entrypoint(target: Path, entrypoint: str):
|
|
|
50
52
|
return getattr(module, func_name)
|
|
51
53
|
|
|
52
54
|
|
|
55
|
+
_NODE_RUNNER = str(Path(__file__).resolve().parent / "_node_runner.mjs")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_task_node(task_id, store, text, payload, app_dir, entrypoint) -> None:
|
|
59
|
+
store.set(task_id, status="working")
|
|
60
|
+
task_json = json.dumps({"task_id": task_id, "text": text, "payload": payload}).encode()
|
|
61
|
+
try:
|
|
62
|
+
proc = subprocess.run(
|
|
63
|
+
["node", _NODE_RUNNER, str(app_dir), entrypoint],
|
|
64
|
+
input=task_json, capture_output=True, timeout=300,
|
|
65
|
+
)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
store.set(task_id, status="failed", error="node not found on PATH")
|
|
68
|
+
return
|
|
69
|
+
except subprocess.TimeoutExpired:
|
|
70
|
+
store.set(task_id, status="failed", error="task timed out")
|
|
71
|
+
return
|
|
72
|
+
last = (proc.stdout.decode(errors="replace").strip().splitlines() or [""])[-1]
|
|
73
|
+
try:
|
|
74
|
+
result = json.loads(last)
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
err = proc.stderr.decode(errors="replace")[-2000:] or "no output from node runner"
|
|
77
|
+
store.set(task_id, status="failed", error=err)
|
|
78
|
+
return
|
|
79
|
+
if result.get("status") == "failed":
|
|
80
|
+
store.set(task_id, status="failed", error=result.get("error"))
|
|
81
|
+
return
|
|
82
|
+
store.set(task_id, status="done", reply=result.get("reply"), charges=result.get("charges", []))
|
|
83
|
+
|
|
84
|
+
|
|
53
85
|
def _run_task(task_id: str, store: _TaskStore, text: str, payload: dict) -> None:
|
|
54
86
|
store.set(task_id, status="working")
|
|
55
87
|
task = Task(id=task_id, text=text, payload=payload)
|
|
@@ -68,6 +100,7 @@ def _run_task(task_id: str, store: _TaskStore, text: str, payload: dict) -> None
|
|
|
68
100
|
|
|
69
101
|
class _Handler(BaseHTTPRequestHandler):
|
|
70
102
|
store: _TaskStore
|
|
103
|
+
runner: dict
|
|
71
104
|
|
|
72
105
|
def log_message(self, *args) -> None: # silence default request logging
|
|
73
106
|
pass
|
|
@@ -113,11 +146,15 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
113
146
|
self._json(400, {"error": "body must be a JSON object"})
|
|
114
147
|
return
|
|
115
148
|
task_id = self.store.create()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
149
|
+
r = self.runner
|
|
150
|
+
if r["is_node"]:
|
|
151
|
+
target_fn = _run_task_node
|
|
152
|
+
args = (task_id, self.store, body.get("text", ""), body.get("payload", {}),
|
|
153
|
+
r["target"], r["entrypoint"])
|
|
154
|
+
else:
|
|
155
|
+
target_fn = _run_task
|
|
156
|
+
args = (task_id, self.store, body.get("text", ""), body.get("payload", {}))
|
|
157
|
+
threading.Thread(target=target_fn, args=args, daemon=True).start()
|
|
121
158
|
self._json(202, {"task_id": task_id})
|
|
122
159
|
|
|
123
160
|
|
|
@@ -125,11 +162,17 @@ def serve(path: str, port: int = 0) -> ThreadingHTTPServer:
|
|
|
125
162
|
"""Load grains.toml + the entrypoint and start the task API in a background thread."""
|
|
126
163
|
target = Path(path).resolve()
|
|
127
164
|
config = tomllib.loads((target / "grains.toml").read_text())
|
|
128
|
-
|
|
129
|
-
agent.
|
|
165
|
+
entrypoint = config["agent"]["entrypoint"]
|
|
166
|
+
runtime = str(config["agent"].get("runtime", "python3.12"))
|
|
167
|
+
is_node = runtime.startswith(("node", "nodejs"))
|
|
168
|
+
if is_node and not shutil.which("node"):
|
|
169
|
+
raise RuntimeError("this is a Node agent but `node` is not on PATH")
|
|
170
|
+
if not is_node:
|
|
171
|
+
agent.handler(_load_entrypoint(target, entrypoint))
|
|
130
172
|
|
|
131
173
|
store = _TaskStore()
|
|
132
|
-
|
|
174
|
+
runner = {"is_node": is_node, "target": target, "entrypoint": entrypoint}
|
|
175
|
+
bound_handler = type("_BoundHandler", (_Handler,), {"store": store, "runner": runner})
|
|
133
176
|
server = ThreadingHTTPServer(("127.0.0.1", port), bound_handler)
|
|
134
177
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
135
178
|
return server
|
|
@@ -48,6 +48,8 @@ def detect_framework(target: Path) -> str:
|
|
|
48
48
|
for framework in _FRAMEWORK_PRIORITY:
|
|
49
49
|
if framework in names:
|
|
50
50
|
return framework
|
|
51
|
+
if not names and (target / "package.json").exists():
|
|
52
|
+
return "node"
|
|
51
53
|
return "none"
|
|
52
54
|
|
|
53
55
|
|
|
@@ -60,7 +62,10 @@ def _sanitize_name(name: str) -> str:
|
|
|
60
62
|
def cmd_init(args) -> int:
|
|
61
63
|
target = Path(args.path).resolve()
|
|
62
64
|
target.mkdir(parents=True, exist_ok=True)
|
|
63
|
-
|
|
65
|
+
|
|
66
|
+
framework = args.template or detect_framework(target)
|
|
67
|
+
app_filename = "grains_app.mjs" if framework == "node" else "grains_app.py"
|
|
68
|
+
app_path = target / app_filename
|
|
64
69
|
toml_path = target / "grains.toml"
|
|
65
70
|
|
|
66
71
|
if not args.force:
|
|
@@ -69,9 +74,8 @@ def cmd_init(args) -> int:
|
|
|
69
74
|
print(f"refusing to overwrite existing file(s): {', '.join(existing)} (use --force)")
|
|
70
75
|
return 1
|
|
71
76
|
|
|
72
|
-
framework = args.template or detect_framework(target)
|
|
73
77
|
app_path.write_text(templates.render_app(framework))
|
|
74
|
-
toml_path.write_text(templates.render_toml(_sanitize_name(target.name)))
|
|
78
|
+
toml_path.write_text(templates.render_toml(_sanitize_name(target.name), framework))
|
|
75
79
|
|
|
76
80
|
print(f"detected framework: {framework}")
|
|
77
81
|
print(f"wrote {app_path}")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`grains login` -- browser sign-in via the Grains OAuth 2.1 server.
|
|
2
|
+
|
|
3
|
+
Same PKCE + dynamic-client-registration flow Claude Code uses against
|
|
4
|
+
mcp.grains.run, with a loopback redirect: register a public client, open the
|
|
5
|
+
browser to the consent page, catch the code on 127.0.0.1, exchange it for a
|
|
6
|
+
deploy token, and store it in ~/.grains/credentials.json (0600). After this,
|
|
7
|
+
every CLI command works with no token pasting; GRAINS_DEPLOY_TOKEN still
|
|
8
|
+
wins when set (CI, scripts).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import stat
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import webbrowser
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from .apiclient import resolve_api
|
|
29
|
+
|
|
30
|
+
CRED_PATH = Path.home() / ".grains" / "credentials.json"
|
|
31
|
+
LOGIN_TIMEOUT_S = 300
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stored_token(api: str) -> str | None:
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(CRED_PATH.read_text())
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
return (data.get(api) or {}).get("token")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def store_token(api: str, token: str) -> None:
|
|
43
|
+
CRED_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(CRED_PATH.read_text())
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
data = {}
|
|
48
|
+
data[api] = {"token": token}
|
|
49
|
+
CRED_PATH.write_text(json.dumps(data, indent=2))
|
|
50
|
+
CRED_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pkce_pair() -> tuple[str, str]:
|
|
54
|
+
verifier = secrets.token_urlsafe(48)
|
|
55
|
+
challenge = (
|
|
56
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
57
|
+
.rstrip(b"=")
|
|
58
|
+
.decode()
|
|
59
|
+
)
|
|
60
|
+
return verifier, challenge
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Callback(BaseHTTPRequestHandler):
|
|
64
|
+
result: dict = {}
|
|
65
|
+
expected_state = ""
|
|
66
|
+
|
|
67
|
+
def log_message(self, *a): # silence
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def do_GET(self):
|
|
71
|
+
q = parse_qs(urlparse(self.path).query)
|
|
72
|
+
ok = q.get("state", [""])[0] == self.expected_state and "code" in q
|
|
73
|
+
if ok:
|
|
74
|
+
_Callback.result = {"code": q["code"][0]}
|
|
75
|
+
else:
|
|
76
|
+
_Callback.result = {"error": q.get("error", ["invalid callback"])[0]}
|
|
77
|
+
body = (
|
|
78
|
+
b"<body style='font-family:system-ui;text-align:center;padding-top:4rem'>"
|
|
79
|
+
+ (b"<h2>Signed in to Grains.</h2>You can close this tab." if ok
|
|
80
|
+
else b"<h2>Sign-in failed.</h2>Return to the terminal.")
|
|
81
|
+
+ b"</body>"
|
|
82
|
+
)
|
|
83
|
+
self.send_response(200)
|
|
84
|
+
self.send_header("Content-Type", "text/html")
|
|
85
|
+
self.end_headers()
|
|
86
|
+
self.wfile.write(body)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def login_flow(api: str, http: httpx.Client | None = None, open_browser=webbrowser.open,
|
|
90
|
+
timeout_s: float = LOGIN_TIMEOUT_S) -> str:
|
|
91
|
+
"""Run the full browser PKCE flow; returns the deploy token."""
|
|
92
|
+
client = http or httpx.Client(timeout=30.0)
|
|
93
|
+
meta = client.get(f"{api}/.well-known/oauth-authorization-server").json()
|
|
94
|
+
|
|
95
|
+
server = HTTPServer(("127.0.0.1", 0), _Callback)
|
|
96
|
+
port = server.server_address[1]
|
|
97
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
98
|
+
|
|
99
|
+
reg = client.post(meta["registration_endpoint"], json={
|
|
100
|
+
"client_name": "grains CLI",
|
|
101
|
+
"redirect_uris": [redirect_uri],
|
|
102
|
+
})
|
|
103
|
+
reg.raise_for_status()
|
|
104
|
+
client_id = reg.json()["client_id"]
|
|
105
|
+
|
|
106
|
+
verifier, challenge = pkce_pair()
|
|
107
|
+
state = secrets.token_urlsafe(16)
|
|
108
|
+
_Callback.expected_state = state
|
|
109
|
+
_Callback.result = {}
|
|
110
|
+
auth_url = meta["authorization_endpoint"] + "?" + urlencode({
|
|
111
|
+
"response_type": "code",
|
|
112
|
+
"client_id": client_id,
|
|
113
|
+
"redirect_uri": redirect_uri,
|
|
114
|
+
"state": state,
|
|
115
|
+
"code_challenge": challenge,
|
|
116
|
+
"code_challenge_method": "S256",
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
120
|
+
thread.start()
|
|
121
|
+
print("Opening your browser to sign in with GitHub…")
|
|
122
|
+
print(f" {auth_url}")
|
|
123
|
+
open_browser(auth_url)
|
|
124
|
+
thread.join(timeout=timeout_s)
|
|
125
|
+
server.server_close()
|
|
126
|
+
if "code" not in _Callback.result:
|
|
127
|
+
raise RuntimeError(_Callback.result.get("error") or "sign-in timed out")
|
|
128
|
+
|
|
129
|
+
resp = client.post(meta["token_endpoint"], data={
|
|
130
|
+
"grant_type": "authorization_code",
|
|
131
|
+
"code": _Callback.result["code"],
|
|
132
|
+
"code_verifier": verifier,
|
|
133
|
+
"client_id": client_id,
|
|
134
|
+
"redirect_uri": redirect_uri,
|
|
135
|
+
})
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
return resp.json()["access_token"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ensure_token(api: str, *, allow_login: bool = True) -> str:
|
|
141
|
+
token = os.environ.get("GRAINS_DEPLOY_TOKEN") or stored_token(api)
|
|
142
|
+
if token:
|
|
143
|
+
return token
|
|
144
|
+
if not allow_login:
|
|
145
|
+
raise RuntimeError("not signed in (run `grains login`)")
|
|
146
|
+
token = login_flow(api)
|
|
147
|
+
store_token(api, token)
|
|
148
|
+
print("Signed in. Token stored in ~/.grains/credentials.json")
|
|
149
|
+
return token
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
153
|
+
api = resolve_api(getattr(args, "api", None))
|
|
154
|
+
try:
|
|
155
|
+
token = login_flow(api)
|
|
156
|
+
except Exception as e: # noqa: BLE001
|
|
157
|
+
print(f"error: {e}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
store_token(api, token)
|
|
160
|
+
print("Signed in. Token stored in ~/.grains/credentials.json")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_logout(args: argparse.Namespace) -> int:
|
|
165
|
+
api = resolve_api(getattr(args, "api", None))
|
|
166
|
+
try:
|
|
167
|
+
data = json.loads(CRED_PATH.read_text())
|
|
168
|
+
data.pop(api, None)
|
|
169
|
+
CRED_PATH.write_text(json.dumps(data, indent=2))
|
|
170
|
+
except (OSError, ValueError):
|
|
171
|
+
pass
|
|
172
|
+
print("Signed out.")
|
|
173
|
+
return 0
|
|
@@ -3,9 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
5
|
|
|
6
|
+
from . import deploy as deploy_cmd
|
|
6
7
|
from . import dev as dev_cmd
|
|
7
8
|
from . import init as init_cmd
|
|
9
|
+
from . import login as login_cmd
|
|
8
10
|
from . import pay as pay_cmd
|
|
11
|
+
from . import ship as ship_cmd
|
|
9
12
|
from . import verify as verify_cmd
|
|
10
13
|
|
|
11
14
|
|
|
@@ -18,7 +21,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
18
21
|
p_init.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
19
22
|
p_init.add_argument(
|
|
20
23
|
"--template",
|
|
21
|
-
choices=["crewai", "langgraph", "langchain", "none"],
|
|
24
|
+
choices=["crewai", "langgraph", "langchain", "none", "node"],
|
|
22
25
|
default=None,
|
|
23
26
|
help="force a template instead of auto-detecting the framework",
|
|
24
27
|
)
|
|
@@ -41,6 +44,37 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
41
44
|
p_pay.add_argument("--caller-token", default=None, help="grains_ct_... caller token, if required")
|
|
42
45
|
p_pay.set_defaults(func=pay_cmd.cmd_pay)
|
|
43
46
|
|
|
47
|
+
p_ship = sub.add_parser("ship", help="sign in, scaffold if needed, deploy, and wait for live")
|
|
48
|
+
p_ship.add_argument("path", nargs="?", default=".")
|
|
49
|
+
p_ship.add_argument("--api", help="control-plane URL (or GRAINS_API_URL)")
|
|
50
|
+
p_ship.set_defaults(func=ship_cmd.cmd_ship)
|
|
51
|
+
|
|
52
|
+
p_login = sub.add_parser("login", help="sign in with GitHub in your browser")
|
|
53
|
+
p_login.add_argument("--api")
|
|
54
|
+
p_login.set_defaults(func=login_cmd.cmd_login)
|
|
55
|
+
|
|
56
|
+
p_logout = sub.add_parser("logout", help="forget the stored deploy token")
|
|
57
|
+
p_logout.add_argument("--api")
|
|
58
|
+
p_logout.set_defaults(func=login_cmd.cmd_logout)
|
|
59
|
+
|
|
60
|
+
p_deploy = sub.add_parser("deploy", help="deploy the agent in this directory to Grains")
|
|
61
|
+
p_deploy.add_argument("path", nargs="?", default=".")
|
|
62
|
+
p_deploy.add_argument("--token", help="deploy token (or set GRAINS_DEPLOY_TOKEN)")
|
|
63
|
+
p_deploy.add_argument("--api", help="control-plane URL (or GRAINS_API_URL)")
|
|
64
|
+
p_deploy.set_defaults(func=deploy_cmd.cmd_deploy)
|
|
65
|
+
|
|
66
|
+
p_logs = sub.add_parser("logs", help="recent logs for a deployed agent")
|
|
67
|
+
p_logs.add_argument("name")
|
|
68
|
+
p_logs.add_argument("--limit", type=int, default=50)
|
|
69
|
+
p_logs.add_argument("--token")
|
|
70
|
+
p_logs.add_argument("--api")
|
|
71
|
+
p_logs.set_defaults(func=deploy_cmd.cmd_logs)
|
|
72
|
+
|
|
73
|
+
p_agents = sub.add_parser("agents", help="list your agents")
|
|
74
|
+
p_agents.add_argument("--token")
|
|
75
|
+
p_agents.add_argument("--api")
|
|
76
|
+
p_agents.set_defaults(func=deploy_cmd.cmd_agents)
|
|
77
|
+
|
|
44
78
|
return parser
|
|
45
79
|
|
|
46
80
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`grains ship` -- the one command: sign in, scaffold, deploy, live.
|
|
2
|
+
|
|
3
|
+
Collapses the whole funnel: browser sign-in if needed (PKCE, no token
|
|
4
|
+
pasting), `grains init` if the directory has no agent yet, deploy, poll
|
|
5
|
+
until live, print the endpoint. `uvx grains-cli ship` is the marketing
|
|
6
|
+
one-liner; this module has to keep it honest.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import tomllib
|
|
14
|
+
from argparse import Namespace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import init as init_cmd
|
|
18
|
+
from .apiclient import GrainsAPIError, GrainsClient, resolve_api, zip_project
|
|
19
|
+
from .login import ensure_token
|
|
20
|
+
|
|
21
|
+
POLL_INTERVAL_S = 3
|
|
22
|
+
BUILD_TIMEOUT_S = 600 # container-tier first builds take minutes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _status(client: GrainsClient, name: str, dep: dict) -> str:
|
|
26
|
+
if dep.get("status") != "building":
|
|
27
|
+
return dep.get("status", "unknown")
|
|
28
|
+
# lazy-reconciled container build: poll the deployment until it settles
|
|
29
|
+
dep_id = dep.get("id")
|
|
30
|
+
deadline = time.monotonic() + BUILD_TIMEOUT_S
|
|
31
|
+
while time.monotonic() < deadline:
|
|
32
|
+
time.sleep(POLL_INTERVAL_S)
|
|
33
|
+
cur = client._req("GET", f"/v1/agents/{name}/deployments/{dep_id}")
|
|
34
|
+
status = cur.get("deployment", cur).get("status")
|
|
35
|
+
if status not in ("building", "pending"):
|
|
36
|
+
return status
|
|
37
|
+
print(" building…", flush=True)
|
|
38
|
+
return "timeout"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_ship(args: argparse.Namespace) -> int:
|
|
42
|
+
path = Path(args.path).resolve()
|
|
43
|
+
api = resolve_api(getattr(args, "api", None))
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
token = ensure_token(api)
|
|
47
|
+
except Exception as e: # noqa: BLE001
|
|
48
|
+
print(f"error: {e}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
if not (path / "grains.toml").exists():
|
|
52
|
+
print(f"no grains.toml in {path.name}/ -- scaffolding")
|
|
53
|
+
rc = init_cmd.cmd_init(Namespace(path=str(path), force=False, template=None))
|
|
54
|
+
if rc != 0:
|
|
55
|
+
return rc
|
|
56
|
+
|
|
57
|
+
name = tomllib.loads((path / "grains.toml").read_text())["agent"]["name"]
|
|
58
|
+
client = GrainsClient(api, token)
|
|
59
|
+
try:
|
|
60
|
+
try:
|
|
61
|
+
client.create_agent(name)
|
|
62
|
+
except GrainsAPIError as e:
|
|
63
|
+
if e.status != 409:
|
|
64
|
+
raise
|
|
65
|
+
result = client.deploy(name, zip_project(path))
|
|
66
|
+
except GrainsAPIError as e:
|
|
67
|
+
print(f"error: {e}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
dep = result.get("deployment", result)
|
|
71
|
+
status = _status(client, name, dep)
|
|
72
|
+
agents_base = api.replace("api.", "agents.")
|
|
73
|
+
print()
|
|
74
|
+
print(f" endpoint {agents_base.removeprefix('https://')}/{name}")
|
|
75
|
+
if result.get("agent", {}).get("did") or dep.get("did"):
|
|
76
|
+
print(f" identity {result.get('agent', {}).get('did') or dep.get('did')}")
|
|
77
|
+
print(f" status {status}")
|
|
78
|
+
print(f" dashboard grains.run/dashboard.html")
|
|
79
|
+
return 0 if status == "live" else 1
|
|
@@ -33,24 +33,37 @@ def handle(task: Task):
|
|
|
33
33
|
return task.reply(str(result))
|
|
34
34
|
'''
|
|
35
35
|
|
|
36
|
+
_NODE_APP = '''// Grains wrapper -- generated by `grains init`.
|
|
37
|
+
export function handle(task) {
|
|
38
|
+
return task.reply("echo: " + task.text);
|
|
39
|
+
}
|
|
40
|
+
'''
|
|
41
|
+
|
|
36
42
|
_APP_TEMPLATES = {
|
|
37
43
|
"crewai": _CREWAI_APP,
|
|
38
44
|
"langchain": _INVOKE_APP,
|
|
39
45
|
"langgraph": _INVOKE_APP,
|
|
40
46
|
"none": _ECHO_APP,
|
|
47
|
+
"node": _NODE_APP,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_RUNTIME_BY_FRAMEWORK = {
|
|
51
|
+
"node": "nodejs20.x",
|
|
41
52
|
}
|
|
53
|
+
_DEFAULT_RUNTIME = "python3.12"
|
|
42
54
|
|
|
43
55
|
|
|
44
56
|
def render_app(framework: str) -> str:
|
|
45
57
|
return _APP_TEMPLATES.get(framework, _ECHO_APP)
|
|
46
58
|
|
|
47
59
|
|
|
48
|
-
def render_toml(name: str) -> str:
|
|
60
|
+
def render_toml(name: str, framework: str = "none") -> str:
|
|
61
|
+
runtime = _RUNTIME_BY_FRAMEWORK.get(framework, _DEFAULT_RUNTIME)
|
|
49
62
|
return (
|
|
50
63
|
"[agent]\n"
|
|
51
64
|
f'name = "{name}"\n'
|
|
52
65
|
'entrypoint = "grains_app:handle"\n'
|
|
53
|
-
'runtime = "
|
|
66
|
+
f'runtime = "{runtime}"\n'
|
|
54
67
|
"public = false\n"
|
|
55
68
|
"\n"
|
|
56
69
|
"[secrets]\n"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grains-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line tools for Grains: build, run, deploy, and pay AI agents.
|
|
5
5
|
Author: Grains
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -23,6 +23,8 @@ Requires-Dist: httpx
|
|
|
23
23
|
Requires-Dist: eth-account>=0.11
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
|
+
<p align="center"><img src="https://grains.run/brand-icon.png" width="96" alt="Grains"></p>
|
|
27
|
+
|
|
26
28
|
# grains-cli
|
|
27
29
|
|
|
28
30
|
Command-line tools for [Grains](https://grains.run) — build, run, deploy, and
|
|
@@ -2,10 +2,14 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
grains_cli/__init__.py
|
|
5
|
+
grains_cli/apiclient.py
|
|
6
|
+
grains_cli/deploy.py
|
|
5
7
|
grains_cli/dev.py
|
|
6
8
|
grains_cli/init.py
|
|
9
|
+
grains_cli/login.py
|
|
7
10
|
grains_cli/main.py
|
|
8
11
|
grains_cli/pay.py
|
|
12
|
+
grains_cli/ship.py
|
|
9
13
|
grains_cli/templates.py
|
|
10
14
|
grains_cli/verify.py
|
|
11
15
|
grains_cli.egg-info/PKG-INFO
|
|
@@ -14,7 +18,9 @@ grains_cli.egg-info/dependency_links.txt
|
|
|
14
18
|
grains_cli.egg-info/entry_points.txt
|
|
15
19
|
grains_cli.egg-info/requires.txt
|
|
16
20
|
grains_cli.egg-info/top_level.txt
|
|
21
|
+
tests/test_deploy.py
|
|
17
22
|
tests/test_dev.py
|
|
18
23
|
tests/test_init.py
|
|
24
|
+
tests/test_init_node.py
|
|
19
25
|
tests/test_pay.py
|
|
20
26
|
tests/test_verify.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "grains-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Command-line tools for Grains: build, run, deploy, and pay AI agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -24,6 +24,7 @@ dependencies = ["grains-sdk[crypto]>=0.1.0", "httpx", "eth-account>=0.11"]
|
|
|
24
24
|
|
|
25
25
|
[project.scripts]
|
|
26
26
|
grains = "grains_cli.main:main"
|
|
27
|
+
grains-cli = "grains_cli.main:main"
|
|
27
28
|
|
|
28
29
|
[project.urls]
|
|
29
30
|
Homepage = "https://grains.run"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import io
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from grains_cli import deploy as deploy_cmd
|
|
10
|
+
from grains_cli.apiclient import GrainsClient, zip_project
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _project(tmp_path: Path, mjs=False):
|
|
14
|
+
(tmp_path / "grains.toml").write_text('[agent]\nname = "cliagent"\nentrypoint = "grains_app:handle"\nruntime = "python3.12"\npublic = false\n')
|
|
15
|
+
(tmp_path / ("grains_app.mjs" if mjs else "grains_app.py")).write_text("x = 1\n")
|
|
16
|
+
(tmp_path / "__pycache__").mkdir()
|
|
17
|
+
(tmp_path / "__pycache__" / "junk.pyc").write_text("junk")
|
|
18
|
+
return tmp_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_zip_project_skips_caches(tmp_path):
|
|
22
|
+
_project(tmp_path)
|
|
23
|
+
names = zipfile.ZipFile(io.BytesIO(zip_project(tmp_path))).namelist()
|
|
24
|
+
assert "grains.toml" in names and "grains_app.py" in names
|
|
25
|
+
assert not any("__pycache__" in n for n in names)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_deploy_creates_then_deploys(tmp_path, monkeypatch):
|
|
29
|
+
_project(tmp_path)
|
|
30
|
+
calls = []
|
|
31
|
+
|
|
32
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
33
|
+
calls.append((req.method, req.url.path))
|
|
34
|
+
if req.url.path == "/v1/agents":
|
|
35
|
+
return httpx.Response(201, json={"agent": {"name": "cliagent"}})
|
|
36
|
+
if req.url.path.endswith("/deployments"):
|
|
37
|
+
assert req.headers["content-type"] == "application/zip"
|
|
38
|
+
return httpx.Response(202, json={"deployment": {"status": "live"}})
|
|
39
|
+
return httpx.Response(404)
|
|
40
|
+
|
|
41
|
+
client = GrainsClient("https://api.test", "grains_dt_x", http=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
42
|
+
monkeypatch.setattr(deploy_cmd, "_client_or_exit", lambda a: client)
|
|
43
|
+
rc = deploy_cmd.cmd_deploy(argparse.Namespace(path=str(tmp_path), token="grains_dt_x", api=None))
|
|
44
|
+
assert rc == 0
|
|
45
|
+
assert ("POST", "/v1/agents") in calls
|
|
46
|
+
assert any(p.endswith("/deployments") for _, p in calls)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_deploy_tolerates_existing_agent(tmp_path):
|
|
50
|
+
_project(tmp_path)
|
|
51
|
+
|
|
52
|
+
def handler(req):
|
|
53
|
+
if req.url.path == "/v1/agents":
|
|
54
|
+
return httpx.Response(409, json={"detail": "taken"})
|
|
55
|
+
if req.url.path.endswith("/deployments"):
|
|
56
|
+
return httpx.Response(202, json={"deployment": {"status": "live"}})
|
|
57
|
+
return httpx.Response(404)
|
|
58
|
+
|
|
59
|
+
client = GrainsClient("https://api.test", "t", http=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
60
|
+
import grains_cli.deploy as d
|
|
61
|
+
orig = d._client_or_exit
|
|
62
|
+
d._client_or_exit = lambda a: client
|
|
63
|
+
try:
|
|
64
|
+
rc = d.cmd_deploy(argparse.Namespace(path=str(tmp_path), token="t", api=None))
|
|
65
|
+
finally:
|
|
66
|
+
d._client_or_exit = orig
|
|
67
|
+
assert rc == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_deploy_requires_token(tmp_path, monkeypatch, capsys):
|
|
71
|
+
_project(tmp_path)
|
|
72
|
+
monkeypatch.delenv("GRAINS_DEPLOY_TOKEN", raising=False)
|
|
73
|
+
rc = None
|
|
74
|
+
try:
|
|
75
|
+
deploy_cmd.cmd_deploy(argparse.Namespace(path=str(tmp_path), token=None, api=None))
|
|
76
|
+
except SystemExit as e:
|
|
77
|
+
rc = e.code
|
|
78
|
+
assert rc == 2
|
|
79
|
+
assert "welcome" in capsys.readouterr().err
|
|
@@ -179,3 +179,23 @@ def test_charges_surfaced_in_task_record(running_server, tmp_path):
|
|
|
179
179
|
record = _wait_for_done(base, body["task_id"])
|
|
180
180
|
assert record["status"] == "done"
|
|
181
181
|
assert record["charges"] == [{"value": "5.00", "currency": "USDC"}]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_dev_runs_node_agent(running_server, tmp_path):
|
|
185
|
+
import shutil
|
|
186
|
+
if not shutil.which("node"):
|
|
187
|
+
import pytest
|
|
188
|
+
pytest.skip("node not available")
|
|
189
|
+
project = tmp_path / "nodeagent"
|
|
190
|
+
project.mkdir()
|
|
191
|
+
(project / "grains_app.mjs").write_text(
|
|
192
|
+
"export function handle(task) {\n return task.reply('node echo: ' + task.text);\n}\n"
|
|
193
|
+
)
|
|
194
|
+
(project / "grains.toml").write_text(
|
|
195
|
+
'[agent]\nname = "n"\nentrypoint = "grains_app:handle"\nruntime = "nodejs20.x"\npublic = false\n'
|
|
196
|
+
)
|
|
197
|
+
base = running_server(project)
|
|
198
|
+
_, body = _post_json(base, "/tasks", {"text": "hi"})
|
|
199
|
+
record = _wait_for_done(base, body["task_id"])
|
|
200
|
+
assert record["status"] == "done"
|
|
201
|
+
assert record["reply"]["text"] == "node echo: hi"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""`grains init --template node` scaffolds a valid grains_app.mjs + grains.toml
|
|
2
|
+
for JS/Node agents -- mirrors test_init.py's coverage of the Python templates.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from grains_cli.init import cmd_init, detect_framework
|
|
12
|
+
from grains_cli.main import build_parser
|
|
13
|
+
from grains_cli.templates import render_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_init(argv):
|
|
17
|
+
return build_parser().parse_args(["init", *argv])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_init(tmp_path, **extra_argv):
|
|
21
|
+
argv = [str(tmp_path)]
|
|
22
|
+
for flag, value in extra_argv.items():
|
|
23
|
+
if value is True:
|
|
24
|
+
argv.append(f"--{flag}")
|
|
25
|
+
elif value is not None:
|
|
26
|
+
argv.extend([f"--{flag}", str(value)])
|
|
27
|
+
return cmd_init(_parse_init(argv))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_node_is_a_valid_template_choice():
|
|
31
|
+
args = _parse_init([".", "--template", "node"])
|
|
32
|
+
assert args.template == "node"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_init_generates_node_app_and_toml(tmp_path):
|
|
36
|
+
rc = _run_init(tmp_path, template="node")
|
|
37
|
+
assert rc == 0
|
|
38
|
+
|
|
39
|
+
app_path = tmp_path / "grains_app.mjs"
|
|
40
|
+
toml_path = tmp_path / "grains.toml"
|
|
41
|
+
assert app_path.exists()
|
|
42
|
+
assert toml_path.exists()
|
|
43
|
+
assert not (tmp_path / "grains_app.py").exists()
|
|
44
|
+
|
|
45
|
+
config = tomllib.loads(toml_path.read_text())
|
|
46
|
+
assert config["agent"]["entrypoint"] == "grains_app:handle"
|
|
47
|
+
assert config["agent"]["runtime"] == "nodejs20.x"
|
|
48
|
+
assert config["agent"]["public"] is False
|
|
49
|
+
assert config["secrets"]["names"] == []
|
|
50
|
+
assert config["egress"]["allow"] == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_node_wrapper_is_at_most_25_lines():
|
|
54
|
+
assert len(render_app("node").splitlines()) <= 25
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
|
|
58
|
+
def test_generated_node_app_parses_with_node_check(tmp_path):
|
|
59
|
+
_run_init(tmp_path, template="node")
|
|
60
|
+
app_path = tmp_path / "grains_app.mjs"
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["node", "--check", str(app_path)], capture_output=True, text=True, check=False
|
|
63
|
+
)
|
|
64
|
+
assert result.returncode == 0, result.stderr
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_detect_framework_node_from_package_json_with_no_python_deps(tmp_path):
|
|
68
|
+
(tmp_path / "package.json").write_text('{"name": "x"}\n')
|
|
69
|
+
assert detect_framework(tmp_path) == "node"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_detect_framework_prefers_python_deps_over_node(tmp_path):
|
|
73
|
+
(tmp_path / "package.json").write_text('{"name": "x"}\n')
|
|
74
|
+
(tmp_path / "requirements.txt").write_text("crewai==0.80.0\n")
|
|
75
|
+
assert detect_framework(tmp_path) == "crewai"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_detect_framework_none_when_no_package_json_and_no_python_deps(tmp_path):
|
|
79
|
+
assert detect_framework(tmp_path) == "none"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_init_force_overwrite_from_python_to_node(tmp_path):
|
|
83
|
+
_run_init(tmp_path, template="none")
|
|
84
|
+
assert (tmp_path / "grains_app.py").exists()
|
|
85
|
+
_run_init(tmp_path, template="node", force=True)
|
|
86
|
+
assert (tmp_path / "grains_app.mjs").exists()
|
|
87
|
+
config = tomllib.loads((tmp_path / "grains.toml").read_text())
|
|
88
|
+
assert config["agent"]["runtime"] == "nodejs20.x"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|