grains-cli 0.1.1__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.1 → grains_cli-0.2.0}/PKG-INFO +1 -1
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/apiclient.py +4 -1
- grains_cli-0.2.0/grains_cli/login.py +173 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/main.py +15 -0
- grains_cli-0.2.0/grains_cli/ship.py +79 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli.egg-info/PKG-INFO +1 -1
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli.egg-info/SOURCES.txt +2 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli.egg-info/entry_points.txt +1 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/pyproject.toml +2 -1
- {grains_cli-0.1.1 → grains_cli-0.2.0}/LICENSE +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/README.md +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/__init__.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/deploy.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/dev.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/init.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/pay.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/templates.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli/verify.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli.egg-info/dependency_links.txt +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli.egg-info/requires.txt +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/grains_cli.egg-info/top_level.txt +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/setup.cfg +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/tests/test_deploy.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/tests/test_dev.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/tests/test_init.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/tests/test_init_node.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/tests/test_pay.py +0 -0
- {grains_cli-0.1.1 → grains_cli-0.2.0}/tests/test_verify.py +0 -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
|
-
|
|
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:
|
|
@@ -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)")
|
|
@@ -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
|
|
@@ -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"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|