acsetra 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- acsetra-0.1.0.dist-info/METADATA +64 -0
- acsetra-0.1.0.dist-info/RECORD +12 -0
- acsetra-0.1.0.dist-info/WHEEL +4 -0
- acsetra-0.1.0.dist-info/entry_points.txt +4 -0
- acsetra_cli/__init__.py +8 -0
- acsetra_cli/__main__.py +140 -0
- acsetra_cli/api.py +88 -0
- acsetra_cli/config.py +79 -0
- acsetra_cli/dev.py +133 -0
- acsetra_cli/docs.py +41 -0
- acsetra_cli/signin.py +67 -0
- acsetra_cli/verbs.py +292 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acsetra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runner CLI — author and run hosted Runner apps from your terminal (the `runner` command).
|
|
5
|
+
Project-URL: Homepage, https://acsetra.com
|
|
6
|
+
Author: Acsetra
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: acsetra,agent,cli,low-code,runner
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# acsetra — the Runner CLI
|
|
14
|
+
|
|
15
|
+
A thin client for building and running **hosted Runner apps** from your terminal.
|
|
16
|
+
It holds no framework logic: every authoring command serializes to `{op, args}`
|
|
17
|
+
and is POSTed to the hosted API, where validation, reserved-code guards, and
|
|
18
|
+
doctor compiles all run server-side. You get the *grammar* of the framework, not
|
|
19
|
+
its implementation.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install acsetra # recommended (isolated)
|
|
25
|
+
# or: pip install acsetra
|
|
26
|
+
runner signin
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Exposes three console scripts — `runner` (primary), `acsetra` (brand alias), and
|
|
30
|
+
`r` (continuity with `./r`).
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
runner signin # device-flow sign-in; caches a token
|
|
36
|
+
runner whoami
|
|
37
|
+
runner app create crm --label "CRM" # create an app scope in your workspace
|
|
38
|
+
runner set put crm contacts ada --json '{"role":"eng"}'
|
|
39
|
+
runner read contacts -S <workspace>.crm
|
|
40
|
+
runner doctor <workspace>.crm && runner compile <workspace>.crm
|
|
41
|
+
runner dev <workspace>.crm # localhost surface proxied to hosted Runner
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## How it fits together
|
|
45
|
+
|
|
46
|
+
- `runner signin` runs the OAuth device flow: it prints a URL + code, you approve
|
|
47
|
+
in the browser, and the CLI caches a token at `~/.config/acsetra/credentials` (0600).
|
|
48
|
+
- `runner docs pull` writes `CLAUDE.md` + `.runner/docs/*` — a compact model of the
|
|
49
|
+
framework, the exact command grammar, and a live skeleton of your app — so a
|
|
50
|
+
coding agent (Claude Code) has the context it needs to build with `runner`.
|
|
51
|
+
- The hosted DB is the source of truth. Local files are **instructions and mirrors**,
|
|
52
|
+
never runtime source. `runner checkout`/`runner dev` produce disposable working
|
|
53
|
+
copies under `.tmp/` for grep and inspection.
|
|
54
|
+
|
|
55
|
+
## Environment overrides
|
|
56
|
+
|
|
57
|
+
| Var | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `ACSETRA_API_BASE` | hosted API origin (default `https://pen.acsetra.com`) |
|
|
60
|
+
| `ACSETRA_TOKEN` | bearer token (skips the cached credential) |
|
|
61
|
+
| `ACSETRA_WORKSPACE` | act in this workspace slug |
|
|
62
|
+
| `ACSETRA_SCOPE` | default app scope for `-S`-less commands |
|
|
63
|
+
|
|
64
|
+
Run `runner help` for the full verb list.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
acsetra_cli/__init__.py,sha256=9U38n-isE94tEn9YaKGBiR7xwnrQ3H3aq6d_rTGp6AA,360
|
|
2
|
+
acsetra_cli/__main__.py,sha256=wEeH6JdwAxaS0RtX_n0vlYBX9C2HHchUazratRuDUIY,5733
|
|
3
|
+
acsetra_cli/api.py,sha256=zZrWd9YrK03hj374AfHR3CTnFhvn52SO49vjy1qK6K4,2822
|
|
4
|
+
acsetra_cli/config.py,sha256=-IOenIL-V33dfCOsxHyR3gd2AAHwbtglM4QXGDk-Kb4,2197
|
|
5
|
+
acsetra_cli/dev.py,sha256=K_3leT4-G1A76gD2lsFIGTdXbK46WCZtzJPBXqMGHvU,5453
|
|
6
|
+
acsetra_cli/docs.py,sha256=ZMuwey-aN6XtmStbu-kNE8XlYFVXgbiOU925Ay_N1RY,1406
|
|
7
|
+
acsetra_cli/signin.py,sha256=i1AxfcQFbxiFGo28Gh4ePpbfl5bnQ5PaoKRcED1TfgM,2525
|
|
8
|
+
acsetra_cli/verbs.py,sha256=pyVhqTO_TJmr64M7gD09ti1RwFkXBEQqJPUd2GGKRhw,12917
|
|
9
|
+
acsetra-0.1.0.dist-info/METADATA,sha256=KyBY_LENDsszXo7f949y5QI0UvE-eMWr7gMRx0q4B2U,2430
|
|
10
|
+
acsetra-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
acsetra-0.1.0.dist-info/entry_points.txt,sha256=rRbLUKJqy41zhDKaDjM51L-lC70LktEaZTF7wGsyQYo,119
|
|
12
|
+
acsetra-0.1.0.dist-info/RECORD,,
|
acsetra_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""acsetra — the Runner CLI client.
|
|
2
|
+
|
|
3
|
+
A thin client over the hosted Runner API. It holds NO framework logic: every
|
|
4
|
+
authoring command serializes to {op, args} and POSTs to /api/v1/op, where the real
|
|
5
|
+
validation, reserved-code guards, and doctor compiles run server-side. Users get
|
|
6
|
+
the GRAMMAR of the framework, not its implementation.
|
|
7
|
+
"""
|
|
8
|
+
__version__ = "0.1.0"
|
acsetra_cli/__main__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""runner — the CLI entry point.
|
|
2
|
+
|
|
3
|
+
Top-level: a handful of client commands (signin, whoami, workspaces, docs, dev,
|
|
4
|
+
usage, tokens). Everything else is an authoring verb that serializes to {op, args}
|
|
5
|
+
and POSTs to /api/v1/op. JSON to stdout; any rejection to stderr + exit 1, so an
|
|
6
|
+
agent loop can branch on the exit code exactly like it does with `./r`.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__, api, config, docs as docs_mod, signin as signin_mod
|
|
12
|
+
from . import dev as dev_mod
|
|
13
|
+
from .verbs import VerbError, to_op
|
|
14
|
+
|
|
15
|
+
HELP = """runner — author and run hosted Runner apps.
|
|
16
|
+
|
|
17
|
+
Session
|
|
18
|
+
runner signin [--api URL] sign in (device flow); caches a token
|
|
19
|
+
runner whoami show the signed-in user + workspace
|
|
20
|
+
runner workspaces [--use SLUG] list workspaces; --use switches the active one
|
|
21
|
+
runner use <slug> set the active workspace for this project
|
|
22
|
+
runner logout forget the cached token
|
|
23
|
+
runner usage [--days N] metered usage vs your plan caps
|
|
24
|
+
runner tokens [new|list|revoke <id>]
|
|
25
|
+
|
|
26
|
+
Context + dev
|
|
27
|
+
runner docs pull [-S scope] refresh CLAUDE.md + .runner/docs/*
|
|
28
|
+
runner dev <app> [--hosted] localhost surface proxied to hosted Runner
|
|
29
|
+
|
|
30
|
+
Authoring (serialized to /api/v1/op)
|
|
31
|
+
runner app create <name> create an app scope; runner apps lists them
|
|
32
|
+
runner ls | inspect <set> list / read value sets (-S scope)
|
|
33
|
+
runner set put <set> <row> --json '{…}' write a row
|
|
34
|
+
runner pipe <code> -S <scope> --writes c:l create a pipeline
|
|
35
|
+
runner w <code> snippet -b - add a worker (stdin body)
|
|
36
|
+
runner css|component|asset|behavior|head <sub> …
|
|
37
|
+
runner read <code> read a value set (me_* = your own zone)
|
|
38
|
+
runner run <pipeline> -i '{…}' ; runner runs <id>
|
|
39
|
+
runner doctor <app> ; runner compile <app>
|
|
40
|
+
runner op <name> --json '{…}' call any op directly
|
|
41
|
+
|
|
42
|
+
Env: ACSETRA_API_BASE, ACSETRA_TOKEN, ACSETRA_WORKSPACE, ACSETRA_SCOPE
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit(result):
|
|
47
|
+
# peel the /op envelope {ok, result}, then the dispatch envelope {ok, op, scope,
|
|
48
|
+
# result}, so the CLI prints the actual payload — not nested confirmation wrappers.
|
|
49
|
+
if isinstance(result, dict) and "result" in result and set(result.keys()) <= {"ok", "result"}:
|
|
50
|
+
result = result["result"]
|
|
51
|
+
if isinstance(result, dict) and "result" in result and set(result.keys()) <= {"ok", "op", "scope", "result"}:
|
|
52
|
+
result = result["result"]
|
|
53
|
+
print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fail(msg, code=1):
|
|
57
|
+
print(f"REJECTED: {msg}", file=sys.stderr)
|
|
58
|
+
sys.exit(code)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv=None):
|
|
62
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
63
|
+
if not argv or argv[0] in ("help", "-h", "--help"):
|
|
64
|
+
print(HELP); return 0
|
|
65
|
+
cmd, rest = argv[0], argv[1:]
|
|
66
|
+
|
|
67
|
+
if cmd in ("version", "--version", "-V"):
|
|
68
|
+
print(f"runner (acsetra) {__version__}"); return 0
|
|
69
|
+
|
|
70
|
+
# --- client-only commands ---------------------------------------------
|
|
71
|
+
if cmd in ("signin", "login"):
|
|
72
|
+
api_base = _opt(rest, "--api")
|
|
73
|
+
return signin_mod.signin(api_base=api_base, open_browser="--no-browser" not in rest)
|
|
74
|
+
if cmd in ("logout", "signout"):
|
|
75
|
+
cred = config.load_user(); cred.pop("token", None); config.save_user(cred)
|
|
76
|
+
print("logged out", file=sys.stderr); return 0
|
|
77
|
+
if cmd == "use":
|
|
78
|
+
if not rest:
|
|
79
|
+
fail("usage: runner use <workspace-slug>")
|
|
80
|
+
config.set_project(workspace=rest[0]); print(f"workspace -> {rest[0]}", file=sys.stderr); return 0
|
|
81
|
+
if cmd == "dev":
|
|
82
|
+
app = rest[0] if rest and not rest[0].startswith("-") else None
|
|
83
|
+
return dev_mod.dev(app, port=int(_opt(rest, "--port") or 8787),
|
|
84
|
+
hosted="--hosted" in rest, open_browser="--no-browser" not in rest)
|
|
85
|
+
if cmd == "docs":
|
|
86
|
+
if rest and rest[0] == "pull":
|
|
87
|
+
return docs_mod.pull(scope=_opt(rest, "-S") or _opt(rest, "--scope"))
|
|
88
|
+
fail("usage: runner docs pull [-S scope]")
|
|
89
|
+
|
|
90
|
+
# --- everything below needs the API; wrap API errors uniformly --------
|
|
91
|
+
try:
|
|
92
|
+
if cmd == "whoami":
|
|
93
|
+
return emit(api.get("/api/v1/whoami")) or 0
|
|
94
|
+
if cmd == "workspaces":
|
|
95
|
+
use = _opt(rest, "--use")
|
|
96
|
+
if use:
|
|
97
|
+
config.set_project(workspace=use)
|
|
98
|
+
return emit(api.get("/api/v1/workspaces")) or 0
|
|
99
|
+
if cmd == "usage":
|
|
100
|
+
days = _opt(rest, "--days") or "30"
|
|
101
|
+
return emit(api.get("/api/v1/usage", {"days": days})) or 0
|
|
102
|
+
if cmd == "tokens":
|
|
103
|
+
return _tokens(rest)
|
|
104
|
+
|
|
105
|
+
# --- authoring verbs ---------------------------------------------
|
|
106
|
+
op, args = to_op(cmd, rest)
|
|
107
|
+
result = api.post_op(op, args)
|
|
108
|
+
emit(result)
|
|
109
|
+
return 0
|
|
110
|
+
except VerbError as e:
|
|
111
|
+
fail(str(e), 2)
|
|
112
|
+
except api.ApiError as e:
|
|
113
|
+
detail = ""
|
|
114
|
+
if e.detail and isinstance(e.detail, dict):
|
|
115
|
+
extra = {k: v for k, v in e.detail.items() if k != "error"}
|
|
116
|
+
if extra:
|
|
117
|
+
detail = " " + json.dumps(extra, default=str)
|
|
118
|
+
fail(f"{e}{detail}", 1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tokens(rest):
|
|
122
|
+
if rest and rest[0] == "new":
|
|
123
|
+
out = api.post("/api/v1/tokens", {"name": _opt(rest, "--name") or "cli"})
|
|
124
|
+
print(json.dumps(out, indent=2)); return 0
|
|
125
|
+
if rest and rest[0] == "revoke" and len(rest) > 1:
|
|
126
|
+
return emit(api.delete(f"/api/v1/tokens/{rest[1]}")) or 0
|
|
127
|
+
return emit(api.get("/api/v1/tokens")) or 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _opt(rest, flag):
|
|
131
|
+
"""Pull `--flag value` out of a flat arg list (for client-only commands)."""
|
|
132
|
+
if flag in rest:
|
|
133
|
+
i = rest.index(flag)
|
|
134
|
+
if i + 1 < len(rest):
|
|
135
|
+
return rest[i + 1]
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main() or 0)
|
acsetra_cli/api.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The HTTP layer — every command ends up here.
|
|
2
|
+
|
|
3
|
+
A tiny wrapper over httpx that attaches the bearer token + workspace header and
|
|
4
|
+
turns non-2xx responses into a clean ApiError (so the CLI can print the server's
|
|
5
|
+
message and exit 1, the same branch contract as ./r's REJECTED).
|
|
6
|
+
"""
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(Exception):
|
|
13
|
+
def __init__(self, message, status=0, detail=None):
|
|
14
|
+
self.status, self.detail = status, detail or {}
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _headers(auth=True):
|
|
19
|
+
h = {"Content-Type": "application/json"}
|
|
20
|
+
if auth:
|
|
21
|
+
tok = config.token()
|
|
22
|
+
if not tok:
|
|
23
|
+
raise ApiError("not signed in — run `runner signin`", 401)
|
|
24
|
+
h["Authorization"] = "Bearer " + tok
|
|
25
|
+
ws = config.workspace()
|
|
26
|
+
if ws:
|
|
27
|
+
h["X-Runner-Workspace"] = ws
|
|
28
|
+
return h
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _base():
|
|
32
|
+
return config.api_base()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check(resp):
|
|
36
|
+
if resp.status_code // 100 == 2:
|
|
37
|
+
return resp.json() if resp.content else {}
|
|
38
|
+
try:
|
|
39
|
+
body = resp.json()
|
|
40
|
+
msg = body.get("error") or body.get("detail") or resp.text
|
|
41
|
+
except Exception:
|
|
42
|
+
body, msg = {}, resp.text
|
|
43
|
+
raise ApiError(msg, resp.status_code, body)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def post_op(op, args, *, timeout=120):
|
|
47
|
+
"""The one authoring call: POST /api/v1/op {op, args}."""
|
|
48
|
+
with httpx.Client(timeout=timeout) as c:
|
|
49
|
+
r = c.post(_base() + "/api/v1/op", json={"op": op, "args": args}, headers=_headers())
|
|
50
|
+
return _check(r)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get(path, params=None, *, auth=True, timeout=60):
|
|
54
|
+
with httpx.Client(timeout=timeout) as c:
|
|
55
|
+
r = c.get(_base() + path, params=params, headers=_headers(auth))
|
|
56
|
+
return _check(r)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def post(path, body=None, *, auth=True, timeout=60):
|
|
60
|
+
with httpx.Client(timeout=timeout) as c:
|
|
61
|
+
r = c.post(_base() + path, json=body or {}, headers=_headers(auth))
|
|
62
|
+
return _check(r)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def delete(path, *, timeout=60):
|
|
66
|
+
with httpx.Client(timeout=timeout) as c:
|
|
67
|
+
r = c.delete(_base() + path, headers=_headers())
|
|
68
|
+
return _check(r)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- device flow (used by signin; no bearer yet) ---------------------------
|
|
72
|
+
def device_start(token_name="cli"):
|
|
73
|
+
with httpx.Client(timeout=30) as c:
|
|
74
|
+
r = c.post(_base() + "/api/v1/auth/device/start", json={"token_name": token_name},
|
|
75
|
+
headers={"Content-Type": "application/json"})
|
|
76
|
+
return _check(r)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def device_token(device_code):
|
|
80
|
+
"""Poll the grant. Returns (status_code, body) WITHOUT raising, because the
|
|
81
|
+
poll's 202/410/403 are control signals, not errors."""
|
|
82
|
+
with httpx.Client(timeout=30) as c:
|
|
83
|
+
r = c.post(_base() + "/api/v1/auth/device/token", json={"device_code": device_code},
|
|
84
|
+
headers={"Content-Type": "application/json"})
|
|
85
|
+
try:
|
|
86
|
+
return r.status_code, r.json()
|
|
87
|
+
except Exception:
|
|
88
|
+
return r.status_code, {}
|
acsetra_cli/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Config + credential resolution.
|
|
2
|
+
|
|
3
|
+
Two stores, resolved in order (env wins, then project, then user):
|
|
4
|
+
- env: ACSETRA_API_BASE / ACSETRA_TOKEN / ACSETRA_WORKSPACE
|
|
5
|
+
- project: ./.runner/config.json (api_base, workspace, default_scope, dev_port)
|
|
6
|
+
- user: ~/.config/acsetra/credentials (api_base, token, workspace, user_sub)
|
|
7
|
+
|
|
8
|
+
The token only ever lives in the user credential file (chmod 600), never in the
|
|
9
|
+
project config (which is safe to commit). signin writes the user store; `--api`
|
|
10
|
+
and workspace switches update the project store.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
DEFAULT_API_BASE = "https://pen.acsetra.com"
|
|
18
|
+
|
|
19
|
+
USER_DIR = Path(os.environ.get("ACSETRA_HOME") or (Path.home() / ".config" / "acsetra"))
|
|
20
|
+
CRED_PATH = USER_DIR / "credentials"
|
|
21
|
+
PROJECT_DIR = Path(".runner")
|
|
22
|
+
PROJECT_CFG = PROJECT_DIR / "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_json(path):
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(Path(path).read_text())
|
|
28
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_user():
|
|
33
|
+
return _read_json(CRED_PATH)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_project():
|
|
37
|
+
return _read_json(PROJECT_CFG)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_user(data):
|
|
41
|
+
USER_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
CRED_PATH.write_text(json.dumps(data, indent=2))
|
|
43
|
+
try:
|
|
44
|
+
CRED_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 — secrets are user-only
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_project(data):
|
|
50
|
+
PROJECT_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
PROJECT_CFG.write_text(json.dumps(data, indent=2))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def api_base():
|
|
55
|
+
return (os.environ.get("ACSETRA_API_BASE")
|
|
56
|
+
or load_project().get("api_base")
|
|
57
|
+
or load_user().get("api_base")
|
|
58
|
+
or DEFAULT_API_BASE).rstrip("/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def token():
|
|
62
|
+
return os.environ.get("ACSETRA_TOKEN") or load_user().get("token")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def workspace():
|
|
66
|
+
return (os.environ.get("ACSETRA_WORKSPACE")
|
|
67
|
+
or load_project().get("workspace")
|
|
68
|
+
or load_user().get("workspace"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_scope():
|
|
72
|
+
return os.environ.get("ACSETRA_SCOPE") or load_project().get("default_scope")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_project(**kw):
|
|
76
|
+
cfg = load_project()
|
|
77
|
+
cfg.update({k: v for k, v in kw.items() if v is not None})
|
|
78
|
+
save_project(cfg)
|
|
79
|
+
return cfg
|
acsetra_cli/dev.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""runner dev <app> — localhost ergonomics over the hosted engine (prod.md §Local dev).
|
|
2
|
+
|
|
3
|
+
The app is rows in hosted Runner, so "local dev" means: make the hosted app
|
|
4
|
+
comfortable to work on from a local URL. This starts a small localhost server that
|
|
5
|
+
proxies the runtime surface (/, /bundle, /feed, /run, /read, assets) to hosted
|
|
6
|
+
Runner, attaching a session cookie obtained by trading the workspace bearer for a
|
|
7
|
+
scoped dev session. The browser gets same-origin localhost ergonomics; the real
|
|
8
|
+
engine, DB, workers, auth, and metering stay server-side.
|
|
9
|
+
|
|
10
|
+
runner dev <app> proxy on http://127.0.0.1:8787
|
|
11
|
+
runner dev <app> --hosted just open the canonical hosted URL (no proxy)
|
|
12
|
+
"""
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import webbrowser
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from . import api, config
|
|
22
|
+
|
|
23
|
+
# hop-by-hop headers we must not forward (RFC 7230 §6.1) + ones httpx re-derives.
|
|
24
|
+
_STRIP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
25
|
+
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
|
|
26
|
+
"accept-encoding"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dev(app, port=8787, hosted=False, open_browser=True):
|
|
30
|
+
base = config.api_base()
|
|
31
|
+
scope = app or config.default_scope()
|
|
32
|
+
if not scope:
|
|
33
|
+
print("runner dev: which app? pass a scope, e.g. `runner dev acme.crm`", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
|
|
36
|
+
if hosted:
|
|
37
|
+
url = f"{base}/?scope={scope}"
|
|
38
|
+
print(f"Opening hosted app: {url}", file=sys.stderr)
|
|
39
|
+
if open_browser:
|
|
40
|
+
try: webbrowser.open(url)
|
|
41
|
+
except Exception: pass
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
# Trade the bearer for a runtime session cookie scoped to this app.
|
|
45
|
+
try:
|
|
46
|
+
sess = api.post("/api/v1/dev/session", {"scope": scope})
|
|
47
|
+
except api.ApiError as e:
|
|
48
|
+
print(f"runner dev: could not open a dev session: {e}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
cookie = f"{sess['cookie_name']}={sess['cookie_value']}"
|
|
51
|
+
prefix = sess.get("prefix", "")
|
|
52
|
+
target = urlparse(base)
|
|
53
|
+
upstream = f"{target.scheme}://{target.netloc}"
|
|
54
|
+
|
|
55
|
+
handler = _make_handler(upstream, cookie, scope)
|
|
56
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
57
|
+
local = f"http://127.0.0.1:{port}{prefix}/?scope={scope}"
|
|
58
|
+
print(f"runner dev: proxying {scope} → {upstream}", file=sys.stderr)
|
|
59
|
+
print(f" open {local}", file=sys.stderr)
|
|
60
|
+
print(" (Ctrl-C to stop)", file=sys.stderr)
|
|
61
|
+
if open_browser:
|
|
62
|
+
threading.Timer(0.6, lambda: _safe_open(local)).start()
|
|
63
|
+
try:
|
|
64
|
+
httpd.serve_forever()
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
print("\nrunner dev: stopped", file=sys.stderr)
|
|
67
|
+
finally:
|
|
68
|
+
httpd.server_close()
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _safe_open(url):
|
|
73
|
+
try: webbrowser.open(url)
|
|
74
|
+
except Exception: pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_handler(upstream, cookie, scope):
|
|
78
|
+
class Proxy(BaseHTTPRequestHandler):
|
|
79
|
+
protocol_version = "HTTP/1.1"
|
|
80
|
+
|
|
81
|
+
def log_message(self, *a):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def _fwd(self, method):
|
|
85
|
+
length = int(self.headers.get("content-length", 0) or 0)
|
|
86
|
+
body = self.rfile.read(length) if length else None
|
|
87
|
+
headers = {k: v for k, v in self.headers.items() if k.lower() not in _STRIP}
|
|
88
|
+
headers["cookie"] = cookie + ("; " + headers["cookie"] if headers.get("cookie") else "")
|
|
89
|
+
url = upstream + self.path
|
|
90
|
+
is_sse = self.path.split("?")[0].endswith("/feed")
|
|
91
|
+
try:
|
|
92
|
+
if is_sse:
|
|
93
|
+
return self._stream(method, url, headers, body)
|
|
94
|
+
with httpx.Client(timeout=120, follow_redirects=False) as c:
|
|
95
|
+
r = c.request(method, url, headers=headers, content=body)
|
|
96
|
+
self._respond(r.status_code, r.headers, r.content)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
self._respond(502, {"content-type": "text/plain"}, f"dev proxy error: {e}".encode())
|
|
99
|
+
|
|
100
|
+
def _stream(self, method, url, headers, body):
|
|
101
|
+
with httpx.Client(timeout=None) as c:
|
|
102
|
+
with c.stream(method, url, headers=headers, content=body) as r:
|
|
103
|
+
self.send_response(r.status_code)
|
|
104
|
+
for k, v in r.headers.items():
|
|
105
|
+
if k.lower() not in _STRIP and k.lower() != "content-length":
|
|
106
|
+
self.send_header(k, v)
|
|
107
|
+
self.send_header("connection", "keep-alive")
|
|
108
|
+
self.end_headers()
|
|
109
|
+
try:
|
|
110
|
+
for chunk in r.iter_raw():
|
|
111
|
+
if chunk:
|
|
112
|
+
self.wfile.write(chunk); self.wfile.flush()
|
|
113
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
def _respond(self, status, headers, content):
|
|
117
|
+
self.send_response(status)
|
|
118
|
+
for k, v in headers.items():
|
|
119
|
+
if k.lower() not in _STRIP:
|
|
120
|
+
self.send_header(k, v)
|
|
121
|
+
self.send_header("content-length", str(len(content)))
|
|
122
|
+
self.end_headers()
|
|
123
|
+
if content:
|
|
124
|
+
try: self.wfile.write(content)
|
|
125
|
+
except (BrokenPipeError, ConnectionResetError): pass
|
|
126
|
+
|
|
127
|
+
do_GET = lambda self: self._fwd("GET")
|
|
128
|
+
do_POST = lambda self: self._fwd("POST")
|
|
129
|
+
do_PUT = lambda self: self._fwd("PUT")
|
|
130
|
+
do_DELETE = lambda self: self._fwd("DELETE")
|
|
131
|
+
do_PATCH = lambda self: self._fwd("PATCH")
|
|
132
|
+
|
|
133
|
+
return Proxy
|
acsetra_cli/docs.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""runner docs pull — hydrate the agent's local context (prod.md §Context pack).
|
|
2
|
+
|
|
3
|
+
Fetches the versioned Markdown pack from the server and writes CLAUDE.md +
|
|
4
|
+
.runner/docs/*. Never clobbers a user's own notes: any path ending in `.local.md`
|
|
5
|
+
is left untouched, and we record the pack version in .runner/config.json so a
|
|
6
|
+
no-change pull is a cheap no-op.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import api, config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def pull(scope=None, target="claude"):
|
|
15
|
+
params = {"target": target}
|
|
16
|
+
sc = scope or config.default_scope()
|
|
17
|
+
if sc:
|
|
18
|
+
params["scope"] = sc
|
|
19
|
+
try:
|
|
20
|
+
pack = api.get("/api/v1/context-pack", params)
|
|
21
|
+
except api.ApiError as e:
|
|
22
|
+
print(f"docs pull failed: {e}", file=sys.stderr)
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
written, skipped = [], []
|
|
26
|
+
for rel, content in pack["files"].items():
|
|
27
|
+
p = Path(rel)
|
|
28
|
+
if p.name.endswith(".local.md"):
|
|
29
|
+
skipped.append(rel)
|
|
30
|
+
continue
|
|
31
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
p.write_text(content)
|
|
33
|
+
written.append(rel)
|
|
34
|
+
|
|
35
|
+
config.set_project(context_version=pack.get("version"), workspace=pack.get("workspace"))
|
|
36
|
+
print(f"docs: wrote {len(written)} files (version {pack.get('version')})", file=sys.stderr)
|
|
37
|
+
for w in written:
|
|
38
|
+
print(f" {w}", file=sys.stderr)
|
|
39
|
+
if skipped:
|
|
40
|
+
print(f" kept {len(skipped)} *.local.md untouched", file=sys.stderr)
|
|
41
|
+
return 0
|
acsetra_cli/signin.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""runner signin — the OAuth device flow (prod.md §Sign-in).
|
|
2
|
+
|
|
3
|
+
Print a URL + short code, the human approves it in the browser, the CLI polls
|
|
4
|
+
until it gets a token, and the token is cached at ~/.config/acsetra/credentials
|
|
5
|
+
(0600). No token -> every command tells you to run `runner signin`, so sign-in is
|
|
6
|
+
structurally required, not a flag.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
from . import api, config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def signin(api_base=None, token_name="cli", open_browser=True):
|
|
16
|
+
if api_base:
|
|
17
|
+
config.set_project(api_base=api_base.rstrip("/"))
|
|
18
|
+
base = config.api_base()
|
|
19
|
+
print(f"Signing in to {base}", file=sys.stderr)
|
|
20
|
+
try:
|
|
21
|
+
start = api.device_start(token_name)
|
|
22
|
+
except api.ApiError as e:
|
|
23
|
+
print(f"could not start sign-in: {e}", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
user_code = start["user_code"]
|
|
27
|
+
verify = start.get("verification_uri_complete") or start.get("verification_uri")
|
|
28
|
+
interval = max(1, int(start.get("interval", 5)))
|
|
29
|
+
device_code = start["device_code"]
|
|
30
|
+
|
|
31
|
+
print("\n To sign in, visit:\n")
|
|
32
|
+
print(f" {verify}\n")
|
|
33
|
+
print(f" and confirm the code: {user_code}\n", file=sys.stderr)
|
|
34
|
+
if open_browser:
|
|
35
|
+
try:
|
|
36
|
+
webbrowser.open(verify)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
deadline = time.time() + int(start.get("expires_in", 900))
|
|
41
|
+
while time.time() < deadline:
|
|
42
|
+
time.sleep(interval)
|
|
43
|
+
status, body = api.device_token(device_code)
|
|
44
|
+
st = body.get("status")
|
|
45
|
+
if status == 200 and body.get("token"):
|
|
46
|
+
_persist(base, body)
|
|
47
|
+
print(f"\n✓ Signed in. Workspace: {body.get('workspace')}", file=sys.stderr)
|
|
48
|
+
return 0
|
|
49
|
+
if st == "denied":
|
|
50
|
+
print("\n✗ Sign-in was denied.", file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
if st in ("expired",) or status == 410:
|
|
53
|
+
print("\n✗ The code expired. Run `runner signin` again.", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
# 202 pending / 404 not-yet-visible -> keep polling
|
|
56
|
+
print("\n✗ Timed out waiting for approval.", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _persist(base, body):
|
|
61
|
+
cred = config.load_user()
|
|
62
|
+
cred.update({"api_base": base, "token": body["token"],
|
|
63
|
+
"workspace": body.get("workspace"), "scope_root": body.get("scope_root")})
|
|
64
|
+
config.save_user(cred)
|
|
65
|
+
# mirror the workspace into the project config (safe to commit; no token)
|
|
66
|
+
config.set_project(api_base=base, workspace=body.get("workspace"),
|
|
67
|
+
default_scope=body.get("scope_root"))
|
acsetra_cli/verbs.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""The verb grammar — argv turned into {op, args}.
|
|
2
|
+
|
|
3
|
+
A thin client can't know each op's schema, but the framework's grammar is small
|
|
4
|
+
and stable, so we carry a compact map of POSITIONAL arg names per op and let
|
|
5
|
+
everything else arrive as --flags / --json. The server binds args BY NAME, so the
|
|
6
|
+
client only has to get the right names into the bag — positional or flag, either
|
|
7
|
+
works. Short names (`w`, `i`, `ls`) and the `-b -` stdin body convention mirror
|
|
8
|
+
`./r`. Anything without a friendly verb is still reachable via `runner op <name>`.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
# Arg names whose values are parsed as JSON (or @file). Everything else is a string.
|
|
14
|
+
# ('writes' is NOT here: for pipe.set it is the `code:label` shorthand, for a worker
|
|
15
|
+
# it is a bare set code — both strings, handled in their verb branches.)
|
|
16
|
+
JSON_ARGS = {"meta", "schema", "decls", "tree", "props", "parts", "input",
|
|
17
|
+
"attrs", "config", "config_schema", "storage", "allow_hosts", "spec"}
|
|
18
|
+
|
|
19
|
+
# For these ops a bare `--json '{…}'` IS the primary payload arg (mirrors ./r, where
|
|
20
|
+
# `set put … --json` is the row meta). Anywhere else, --json merges into args.
|
|
21
|
+
PRIMARY_JSON = {
|
|
22
|
+
"set.put": "meta", "set.fetch_url": "meta", "set.declare_schema": "schema",
|
|
23
|
+
"css.set_rule": "decls", "component.set": "tree", "component.set_route": "props",
|
|
24
|
+
"behavior.attach": "config", "head.set": "attrs",
|
|
25
|
+
}
|
|
26
|
+
INT_ARGS = {"ordinal", "rate", "timeout", "retries", "at", "max_bytes", "ttl_days", "days"}
|
|
27
|
+
FLOAT_ARGS = {"backoff"}
|
|
28
|
+
BOOL_ARGS = {"replace", "force", "keep", "probe", "enabled", "muted", "autoplay",
|
|
29
|
+
"controls", "auth_flow"}
|
|
30
|
+
|
|
31
|
+
# Ordered positional arg names per op (UX only; the server binds by name).
|
|
32
|
+
POS = {
|
|
33
|
+
"set.create": ["code"], "set.put": ["set_code", "row_code"],
|
|
34
|
+
"set.declare_schema": ["code"], "set.fetch_url": ["set_code", "row_code", "url"],
|
|
35
|
+
"set.remove_row": ["set_code", "row_code"], "set.drop": ["set_code"],
|
|
36
|
+
"set.describe": ["set_code"], "set.rows": ["set_code"],
|
|
37
|
+
"pipe.set": ["code"], "pipe.show": ["code"], "pipe.remove": ["code"],
|
|
38
|
+
"css.set_class": ["code"], "css.set_rule": ["class_code", "variant"],
|
|
39
|
+
"css.clear_rule": ["class_code"], "css.attach": ["class_code", "target"],
|
|
40
|
+
"css.detach": ["code"], "css.describe": ["class_code"], "css.resolve": ["page"],
|
|
41
|
+
"asset.add": ["code", "kind"], "asset.attach": ["asset", "slot"],
|
|
42
|
+
"asset.detach": ["code"], "asset.describe": ["code"], "asset.resolve": ["page"],
|
|
43
|
+
"behavior.add": ["code"], "behavior.set_enabled": ["code", "enabled"],
|
|
44
|
+
"behavior.remove": ["code"], "behavior.attach": ["behavior_code", "target"],
|
|
45
|
+
"behavior.set_attachment_enabled": ["code", "enabled"], "behavior.detach": ["code"],
|
|
46
|
+
"behavior.describe": ["code"],
|
|
47
|
+
"component.set": ["code"], "component.remove": ["code"],
|
|
48
|
+
"component.set_route": ["code", "component"], "component.remove_route": ["code"],
|
|
49
|
+
"component.describe": ["code"],
|
|
50
|
+
"head.set": ["code"], "head.remove": ["code"],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Planes that take `runner <plane> <subverb> …` (subverb maps to op `<plane>.<subverb>`).
|
|
54
|
+
PLANES = {"set", "css", "asset", "behavior", "component", "head"}
|
|
55
|
+
|
|
56
|
+
# Short flag aliases (match ./r): -w writes, -k keys, -u url, -f body_from, etc.
|
|
57
|
+
SHORT = {"w": "writes", "k": "keys", "u": "url", "f": "body_from", "H": "handler",
|
|
58
|
+
"X": "method", "s": "spec", "l": "label", "i": "input"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class VerbError(Exception):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def coerce(name, value):
|
|
66
|
+
if value is None:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
if name in JSON_ARGS:
|
|
70
|
+
if isinstance(value, str) and value.startswith("@"):
|
|
71
|
+
with open(value[1:]) as f:
|
|
72
|
+
return json.load(f)
|
|
73
|
+
return json.loads(value) if isinstance(value, str) else value
|
|
74
|
+
if name in INT_ARGS:
|
|
75
|
+
return int(value)
|
|
76
|
+
if name in FLOAT_ARGS:
|
|
77
|
+
return float(value)
|
|
78
|
+
if name in BOOL_ARGS:
|
|
79
|
+
return str(value).lower() in ("1", "true", "yes", "on")
|
|
80
|
+
except (ValueError, OSError) as e:
|
|
81
|
+
raise VerbError(f"bad value for --{name}: {e}")
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_body(value):
|
|
86
|
+
return sys.stdin.read() if value == "-" else value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _walk(rest):
|
|
90
|
+
"""Split the argv tail into (positionals, flags). Handles -S/--scope, --json,
|
|
91
|
+
-b/--body, bare boolean flags, and --name value pairs."""
|
|
92
|
+
positionals, flags = [], {}
|
|
93
|
+
i = 0
|
|
94
|
+
while i < len(rest):
|
|
95
|
+
tok = rest[i]
|
|
96
|
+
if tok in ("-S", "--scope"):
|
|
97
|
+
flags["scope"] = rest[i + 1]; i += 2; continue
|
|
98
|
+
if tok in ("-b", "--body"):
|
|
99
|
+
flags["__body"] = _read_body(rest[i + 1]); i += 2; continue
|
|
100
|
+
if tok == "--json":
|
|
101
|
+
blob = rest[i + 1]
|
|
102
|
+
obj = (json.load(open(blob[1:])) if blob.startswith("@") else json.loads(blob))
|
|
103
|
+
flags.setdefault("__json", {}).update(obj); i += 2; continue
|
|
104
|
+
if tok.startswith("--") or (tok.startswith("-") and len(tok) == 2 and not tok[1:].isdigit()):
|
|
105
|
+
name = tok.lstrip("-").replace("-", "_")
|
|
106
|
+
name = SHORT.get(name, name)
|
|
107
|
+
nxt = rest[i + 1] if i + 1 < len(rest) else None
|
|
108
|
+
if name in BOOL_ARGS and (nxt is None or nxt.startswith("-")
|
|
109
|
+
or nxt.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off")):
|
|
110
|
+
flags[name] = True; i += 1; continue
|
|
111
|
+
if nxt is None:
|
|
112
|
+
raise VerbError(f"missing value for {tok}")
|
|
113
|
+
flags[name] = nxt; i += 2; continue
|
|
114
|
+
positionals.append(tok); i += 1
|
|
115
|
+
return positionals, flags
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _assemble(op, positionals, flags):
|
|
119
|
+
args = {}
|
|
120
|
+
names = POS.get(op, [])
|
|
121
|
+
for idx, val in enumerate(positionals):
|
|
122
|
+
if idx < len(names):
|
|
123
|
+
args[names[idx]] = coerce(names[idx], val)
|
|
124
|
+
else:
|
|
125
|
+
raise VerbError(f"too many positional args for `{op}` (expected {len(names)})")
|
|
126
|
+
if "scope" in flags:
|
|
127
|
+
args["scope"] = flags.pop("scope")
|
|
128
|
+
if "__json" in flags:
|
|
129
|
+
blob = flags.pop("__json")
|
|
130
|
+
prim = PRIMARY_JSON.get(op)
|
|
131
|
+
if prim and prim not in args:
|
|
132
|
+
args[prim] = blob # `--json` is this op's primary payload
|
|
133
|
+
else:
|
|
134
|
+
args.update(blob) # otherwise merge keys into args
|
|
135
|
+
flags.pop("__body", None) # body only meaningful to special verbs (handled there)
|
|
136
|
+
for k, v in flags.items():
|
|
137
|
+
args[k] = coerce(k, v)
|
|
138
|
+
return args
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# The public entry: map (verb, rest) -> (op_name, args). Returns None for op when
|
|
143
|
+
# the verb is a client-only command (signin, dev, …) handled in __main__.
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
def to_op(verb, rest):
|
|
146
|
+
verb = verb.replace("-", "_")
|
|
147
|
+
|
|
148
|
+
if verb in ("w", "work"):
|
|
149
|
+
return _worker(rest)
|
|
150
|
+
if verb == "op": # universal escape hatch
|
|
151
|
+
if not rest:
|
|
152
|
+
raise VerbError("usage: runner op <name> [--json '{…}'] [--flag value]")
|
|
153
|
+
pos, flags = _walk(rest[1:])
|
|
154
|
+
args = _assemble(rest[0], pos, flags)
|
|
155
|
+
if "__body" in flags:
|
|
156
|
+
args["body"] = flags["__body"]
|
|
157
|
+
return rest[0], args
|
|
158
|
+
if verb in ("inspect", "i"):
|
|
159
|
+
pos, flags = _walk(rest)
|
|
160
|
+
op = "set.rows" if pos else "set.list"
|
|
161
|
+
return op, _assemble(op, pos, flags)
|
|
162
|
+
if verb == "ls":
|
|
163
|
+
_, flags = _walk(rest)
|
|
164
|
+
return "set.list", _assemble("set.list", [], flags)
|
|
165
|
+
if verb in ("pipe",):
|
|
166
|
+
if rest and rest[0] in ("show", "list", "remove"):
|
|
167
|
+
sub = rest[0]
|
|
168
|
+
op = "pipe." + ("set" if sub == "set" else sub)
|
|
169
|
+
op = {"show": "pipe.show", "list": "pipe.list", "remove": "pipe.remove"}[sub]
|
|
170
|
+
pos, flags = _walk(rest[1:])
|
|
171
|
+
return op, _assemble(op, pos, flags)
|
|
172
|
+
pos, flags = _walk(rest)
|
|
173
|
+
args = _assemble("pipe.set", pos, flags)
|
|
174
|
+
if isinstance(args.get("writes"), str): # --writes code:label -> {code,label}
|
|
175
|
+
c, _, lbl = args["writes"].partition(":")
|
|
176
|
+
args["writes"] = {"code": c, "label": lbl or c}
|
|
177
|
+
return "pipe.set", args
|
|
178
|
+
if verb in ("pipes",):
|
|
179
|
+
_, flags = _walk(rest)
|
|
180
|
+
return "pipe.list", _assemble("pipe.list", [], flags)
|
|
181
|
+
if verb == "show":
|
|
182
|
+
pos, flags = _walk(rest)
|
|
183
|
+
return "pipe.show", _assemble("pipe.show", pos, flags)
|
|
184
|
+
if verb == "read":
|
|
185
|
+
pos, flags = _walk(rest)
|
|
186
|
+
args = {"code": pos[0] if pos else flags.get("code")}
|
|
187
|
+
if "scope" in flags:
|
|
188
|
+
args["scope"] = flags["scope"]
|
|
189
|
+
return "read", args
|
|
190
|
+
if verb == "run":
|
|
191
|
+
pos, flags = _walk(rest)
|
|
192
|
+
args = {"pipeline": pos[0] if pos else flags.get("pipeline")}
|
|
193
|
+
if "input" in flags:
|
|
194
|
+
args["input"] = coerce("input", flags["input"])
|
|
195
|
+
return "run", args
|
|
196
|
+
if verb == "runs":
|
|
197
|
+
pos, _ = _walk(rest)
|
|
198
|
+
return "runs", {"launch": pos[0] if pos else None}
|
|
199
|
+
if verb in ("compile",):
|
|
200
|
+
pos, flags = _walk(rest)
|
|
201
|
+
args = {}
|
|
202
|
+
if pos: args["scope"] = pos[0]
|
|
203
|
+
if "scope" in flags: args["scope"] = flags["scope"]
|
|
204
|
+
return "bundle.compile", args
|
|
205
|
+
if verb in ("doctor",):
|
|
206
|
+
pos, flags = _walk(rest)
|
|
207
|
+
args = {}
|
|
208
|
+
if pos: args["scope"] = pos[0]
|
|
209
|
+
if "scope" in flags: args["scope"] = flags["scope"]
|
|
210
|
+
return "doctor.compile", args
|
|
211
|
+
if verb == "app":
|
|
212
|
+
if rest and rest[0] == "create":
|
|
213
|
+
pos, flags = _walk(rest[1:])
|
|
214
|
+
return "app.create", {"name": pos[0] if pos else None, **({"label": flags["label"]} if "label" in flags else {})}
|
|
215
|
+
if rest and rest[0] == "list":
|
|
216
|
+
return "app.list", {}
|
|
217
|
+
raise VerbError("usage: runner app create <name> [--label L] | runner app list")
|
|
218
|
+
if verb == "apps":
|
|
219
|
+
return "app.list", {}
|
|
220
|
+
|
|
221
|
+
# plane subverb form: runner <plane> <subverb> …
|
|
222
|
+
if verb in PLANES:
|
|
223
|
+
if not rest:
|
|
224
|
+
raise VerbError(f"usage: runner {verb} <subverb> … (e.g. `{verb} list`)")
|
|
225
|
+
sub = rest[0].replace("-", "_")
|
|
226
|
+
# convenience aliases
|
|
227
|
+
sub = {"class": "set_class", "rule": "set_rule"}.get(sub, sub) if verb == "css" else sub
|
|
228
|
+
op = f"{verb}.{sub}"
|
|
229
|
+
pos, flags = _walk(rest[1:])
|
|
230
|
+
args = _assemble(op, pos, flags)
|
|
231
|
+
# planes that take a body (behavior.add) accept -b/--body -> body
|
|
232
|
+
if "__body" in flags:
|
|
233
|
+
args["body"] = flags["__body"]
|
|
234
|
+
return op, args
|
|
235
|
+
|
|
236
|
+
raise VerbError(f"unknown command '{verb}' — try `runner help`")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _worker(rest):
|
|
240
|
+
"""`runner w <code> <tier> …` — assemble a worker spec from tier-specific flags,
|
|
241
|
+
exactly like ./r work. Output op is pipe.add_worker {code, tier, spec, writes?, at?}."""
|
|
242
|
+
if len(rest) < 2:
|
|
243
|
+
raise VerbError("usage: runner w <pipeline-code> <tier> [flags]")
|
|
244
|
+
code, tier = rest[0], rest[1]
|
|
245
|
+
pos, flags = _walk(rest[2:])
|
|
246
|
+
spec = {}
|
|
247
|
+
body = flags.get("__body")
|
|
248
|
+
if tier == "snippet":
|
|
249
|
+
if not body:
|
|
250
|
+
raise VerbError("snippet worker needs -b <code|->")
|
|
251
|
+
spec["body"] = body
|
|
252
|
+
elif tier == "internal":
|
|
253
|
+
if "handler" not in flags:
|
|
254
|
+
raise VerbError("internal worker needs --handler NAME")
|
|
255
|
+
spec["handler"] = flags["handler"]
|
|
256
|
+
elif tier == "httpx":
|
|
257
|
+
if "url" not in flags:
|
|
258
|
+
raise VerbError("httpx worker needs --url")
|
|
259
|
+
spec.update({"url": flags["url"], "method": flags.get("method", "POST"),
|
|
260
|
+
"keys": [k.strip() for k in flags.get("keys", "").split(",") if k.strip()]})
|
|
261
|
+
if "body_from" in flags:
|
|
262
|
+
spec["body_from"] = flags["body_from"]
|
|
263
|
+
elif tier == "read":
|
|
264
|
+
if "set" not in flags:
|
|
265
|
+
raise VerbError("read worker needs --set CODE")
|
|
266
|
+
spec.update({"set": flags["set"], "mode": flags.get("mode", "latest")})
|
|
267
|
+
for k in ("key", "scope", "as"):
|
|
268
|
+
if k in flags:
|
|
269
|
+
spec[k] = flags[k]
|
|
270
|
+
if "read_scope" in flags:
|
|
271
|
+
spec["scope"] = flags["read_scope"]
|
|
272
|
+
elif tier == "llm":
|
|
273
|
+
spec["provider"] = flags.get("provider", "openai")
|
|
274
|
+
spec["user_from"] = flags.get("user_from", "ask")
|
|
275
|
+
for src, dst in (("model", "model"), ("system_from", "system_from"), ("url", "url"),
|
|
276
|
+
("response_path", "response_path"), ("as", "as")):
|
|
277
|
+
if src in flags:
|
|
278
|
+
spec[dst] = flags[src]
|
|
279
|
+
if "keys" in flags:
|
|
280
|
+
spec["keys"] = [k.strip() for k in flags["keys"].split(",") if k.strip()]
|
|
281
|
+
if "spec" in flags:
|
|
282
|
+
spec.update(coerce("spec", flags["spec"]))
|
|
283
|
+
args = {"code": code, "tier": tier, "spec": spec}
|
|
284
|
+
if "writes" in flags:
|
|
285
|
+
args["writes"] = flags["writes"]
|
|
286
|
+
if "w" in flags:
|
|
287
|
+
args["writes"] = flags["w"]
|
|
288
|
+
if "at" in flags:
|
|
289
|
+
args["at"] = coerce("at", flags["at"])
|
|
290
|
+
if "scope" in flags:
|
|
291
|
+
args["scope"] = flags["scope"]
|
|
292
|
+
return "pipe.add_worker", args
|