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.
Files changed (28) hide show
  1. {grains_cli-0.1.0 → grains_cli-0.2.0}/PKG-INFO +3 -1
  2. {grains_cli-0.1.0 → grains_cli-0.2.0}/README.md +2 -0
  3. grains_cli-0.2.0/grains_cli/apiclient.py +81 -0
  4. grains_cli-0.2.0/grains_cli/deploy.py +80 -0
  5. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/dev.py +51 -8
  6. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/init.py +7 -3
  7. grains_cli-0.2.0/grains_cli/login.py +173 -0
  8. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/main.py +35 -1
  9. grains_cli-0.2.0/grains_cli/ship.py +79 -0
  10. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/templates.py +15 -2
  11. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/PKG-INFO +3 -1
  12. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/SOURCES.txt +6 -0
  13. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/entry_points.txt +1 -0
  14. {grains_cli-0.1.0 → grains_cli-0.2.0}/pyproject.toml +2 -1
  15. grains_cli-0.2.0/tests/test_deploy.py +79 -0
  16. {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_dev.py +20 -0
  17. grains_cli-0.2.0/tests/test_init_node.py +88 -0
  18. {grains_cli-0.1.0 → grains_cli-0.2.0}/LICENSE +0 -0
  19. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/__init__.py +0 -0
  20. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/pay.py +0 -0
  21. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli/verify.py +0 -0
  22. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/dependency_links.txt +0 -0
  23. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/requires.txt +0 -0
  24. {grains_cli-0.1.0 → grains_cli-0.2.0}/grains_cli.egg-info/top_level.txt +0 -0
  25. {grains_cli-0.1.0 → grains_cli-0.2.0}/setup.cfg +0 -0
  26. {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_init.py +0 -0
  27. {grains_cli-0.1.0 → grains_cli-0.2.0}/tests/test_pay.py +0 -0
  28. {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.1.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
@@ -1,3 +1,5 @@
1
+ <p align="center"><img src="https://grains.run/brand-icon.png" width="96" alt="Grains"></p>
2
+
1
3
  # grains-cli
2
4
 
3
5
  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
- threading.Thread(
117
- target=_run_task,
118
- args=(task_id, self.store, body.get("text", ""), body.get("payload", {})),
119
- daemon=True,
120
- ).start()
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
- handler_func = _load_entrypoint(target, config["agent"]["entrypoint"])
129
- agent.handler(handler_func)
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
- bound_handler = type("_BoundHandler", (_Handler,), {"store": store})
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
- app_path = target / "grains_app.py"
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 = "python3.12"\n'
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.1.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
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  grains = grains_cli.main:main
3
+ grains-cli = grains_cli.main:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "grains-cli"
7
- version = "0.1.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