grains-cli 0.1.1__tar.gz → 0.2.1__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.1 → grains_cli-0.2.1}/PKG-INFO +1 -1
  2. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/apiclient.py +4 -1
  3. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/deploy.py +22 -0
  4. grains_cli-0.2.1/grains_cli/login.py +173 -0
  5. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/main.py +20 -0
  6. grains_cli-0.2.1/grains_cli/ship.py +79 -0
  7. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli.egg-info/PKG-INFO +1 -1
  8. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli.egg-info/SOURCES.txt +2 -0
  9. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli.egg-info/entry_points.txt +1 -0
  10. {grains_cli-0.1.1 → grains_cli-0.2.1}/pyproject.toml +2 -1
  11. {grains_cli-0.1.1 → grains_cli-0.2.1}/LICENSE +0 -0
  12. {grains_cli-0.1.1 → grains_cli-0.2.1}/README.md +0 -0
  13. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/__init__.py +0 -0
  14. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/dev.py +0 -0
  15. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/init.py +0 -0
  16. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/pay.py +0 -0
  17. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/templates.py +0 -0
  18. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli/verify.py +0 -0
  19. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli.egg-info/dependency_links.txt +0 -0
  20. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli.egg-info/requires.txt +0 -0
  21. {grains_cli-0.1.1 → grains_cli-0.2.1}/grains_cli.egg-info/top_level.txt +0 -0
  22. {grains_cli-0.1.1 → grains_cli-0.2.1}/setup.cfg +0 -0
  23. {grains_cli-0.1.1 → grains_cli-0.2.1}/tests/test_deploy.py +0 -0
  24. {grains_cli-0.1.1 → grains_cli-0.2.1}/tests/test_dev.py +0 -0
  25. {grains_cli-0.1.1 → grains_cli-0.2.1}/tests/test_init.py +0 -0
  26. {grains_cli-0.1.1 → grains_cli-0.2.1}/tests/test_init_node.py +0 -0
  27. {grains_cli-0.1.1 → grains_cli-0.2.1}/tests/test_pay.py +0 -0
  28. {grains_cli-0.1.1 → grains_cli-0.2.1}/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.1
3
+ Version: 0.2.1
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
@@ -27,7 +27,10 @@ def resolve_api(explicit: str | None) -> str:
27
27
 
28
28
 
29
29
  def resolve_token(explicit: str | None) -> str | None:
30
- return explicit or os.environ.get("GRAINS_DEPLOY_TOKEN")
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))
31
34
 
32
35
 
33
36
  def zip_project(path: Path) -> bytes:
@@ -78,3 +78,25 @@ def cmd_agents(args: argparse.Namespace) -> int:
78
78
  vis = "public" if a.get("public") else "private"
79
79
  print(f"{a['name']:<24} {a.get('status', ''):<8} {vis}{price}")
80
80
  return 0
81
+
82
+
83
+ def cmd_balance(args: argparse.Namespace) -> int:
84
+ client = _client_or_exit(args)
85
+ try:
86
+ data = client._req("GET", "/v1/balance")
87
+ except GrainsAPIError as e:
88
+ print(f"error: {e}", file=sys.stderr)
89
+ return 1
90
+ agents = data.get("agents", [])
91
+ if not agents:
92
+ print("No paid calls yet. Set a price with `grains_set_price` in Claude "
93
+ "or from the dashboard, and this fills up.")
94
+ return 0
95
+ cur = data.get("currency", "USDC")
96
+ print(f"{'agent':<24} {'calls':>6} {'gross':>12} {'fee':>10} {'net':>12}")
97
+ for a in agents:
98
+ print(f"{a['agent']:<24} {a['calls']:>6} {a['gross']:>12} {a['fee']:>10} {a['net']:>12}")
99
+ t = data["total"]
100
+ print("-" * 68)
101
+ print(f"{'total':<24} {'':>6} {t['gross']:>12} {t['fee']:>10} {t['net']:>12} {cur}")
102
+ return 0
@@ -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
@@ -6,7 +6,9 @@ import argparse
6
6
  from . import deploy as deploy_cmd
7
7
  from . import dev as dev_cmd
8
8
  from . import init as init_cmd
9
+ from . import login as login_cmd
9
10
  from . import pay as pay_cmd
11
+ from . import ship as ship_cmd
10
12
  from . import verify as verify_cmd
11
13
 
12
14
 
@@ -42,6 +44,19 @@ def build_parser() -> argparse.ArgumentParser:
42
44
  p_pay.add_argument("--caller-token", default=None, help="grains_ct_... caller token, if required")
43
45
  p_pay.set_defaults(func=pay_cmd.cmd_pay)
44
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
+
45
60
  p_deploy = sub.add_parser("deploy", help="deploy the agent in this directory to Grains")
46
61
  p_deploy.add_argument("path", nargs="?", default=".")
47
62
  p_deploy.add_argument("--token", help="deploy token (or set GRAINS_DEPLOY_TOKEN)")
@@ -55,6 +70,11 @@ def build_parser() -> argparse.ArgumentParser:
55
70
  p_logs.add_argument("--api")
56
71
  p_logs.set_defaults(func=deploy_cmd.cmd_logs)
57
72
 
73
+ p_balance = sub.add_parser("balance", help="earnings across your paid agents")
74
+ p_balance.add_argument("--token")
75
+ p_balance.add_argument("--api")
76
+ p_balance.set_defaults(func=deploy_cmd.cmd_balance)
77
+
58
78
  p_agents = sub.add_parser("agents", help="list your agents")
59
79
  p_agents.add_argument("--token")
60
80
  p_agents.add_argument("--api")
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grains-cli
3
- Version: 0.1.1
3
+ Version: 0.2.1
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
@@ -6,8 +6,10 @@ grains_cli/apiclient.py
6
6
  grains_cli/deploy.py
7
7
  grains_cli/dev.py
8
8
  grains_cli/init.py
9
+ grains_cli/login.py
9
10
  grains_cli/main.py
10
11
  grains_cli/pay.py
12
+ grains_cli/ship.py
11
13
  grains_cli/templates.py
12
14
  grains_cli/verify.py
13
15
  grains_cli.egg-info/PKG-INFO
@@ -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.1"
7
+ version = "0.2.1"
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"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes