acsetra 0.1.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.
@@ -0,0 +1,6 @@
1
+ .s2p_cluster
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .env
6
+ .tmp/
acsetra-0.1.0/PKG-INFO ADDED
@@ -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,52 @@
1
+ # acsetra — the Runner CLI
2
+
3
+ A thin client for building and running **hosted Runner apps** from your terminal.
4
+ It holds no framework logic: every authoring command serializes to `{op, args}`
5
+ and is POSTed to the hosted API, where validation, reserved-code guards, and
6
+ doctor compiles all run server-side. You get the *grammar* of the framework, not
7
+ its implementation.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pipx install acsetra # recommended (isolated)
13
+ # or: pip install acsetra
14
+ runner signin
15
+ ```
16
+
17
+ Exposes three console scripts — `runner` (primary), `acsetra` (brand alias), and
18
+ `r` (continuity with `./r`).
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ runner signin # device-flow sign-in; caches a token
24
+ runner whoami
25
+ runner app create crm --label "CRM" # create an app scope in your workspace
26
+ runner set put crm contacts ada --json '{"role":"eng"}'
27
+ runner read contacts -S <workspace>.crm
28
+ runner doctor <workspace>.crm && runner compile <workspace>.crm
29
+ runner dev <workspace>.crm # localhost surface proxied to hosted Runner
30
+ ```
31
+
32
+ ## How it fits together
33
+
34
+ - `runner signin` runs the OAuth device flow: it prints a URL + code, you approve
35
+ in the browser, and the CLI caches a token at `~/.config/acsetra/credentials` (0600).
36
+ - `runner docs pull` writes `CLAUDE.md` + `.runner/docs/*` — a compact model of the
37
+ framework, the exact command grammar, and a live skeleton of your app — so a
38
+ coding agent (Claude Code) has the context it needs to build with `runner`.
39
+ - The hosted DB is the source of truth. Local files are **instructions and mirrors**,
40
+ never runtime source. `runner checkout`/`runner dev` produce disposable working
41
+ copies under `.tmp/` for grep and inspection.
42
+
43
+ ## Environment overrides
44
+
45
+ | Var | Meaning |
46
+ |---|---|
47
+ | `ACSETRA_API_BASE` | hosted API origin (default `https://pen.acsetra.com`) |
48
+ | `ACSETRA_TOKEN` | bearer token (skips the cached credential) |
49
+ | `ACSETRA_WORKSPACE` | act in this workspace slug |
50
+ | `ACSETRA_SCOPE` | default app scope for `-S`-less commands |
51
+
52
+ Run `runner help` for the full verb list.
@@ -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"
@@ -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)
@@ -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, {}
@@ -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
@@ -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
@@ -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
@@ -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"))
@@ -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
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "acsetra"
7
+ version = "0.1.0"
8
+ description = "Runner CLI — author and run hosted Runner apps from your terminal (the `runner` command)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Acsetra" }]
13
+ keywords = ["runner", "acsetra", "cli", "agent", "low-code"]
14
+ dependencies = [
15
+ "httpx>=0.27",
16
+ ]
17
+
18
+ # Three console scripts, one entrypoint (prod.md §Naming): `runner` is what users
19
+ # and agents type; `acsetra` is the brand alias; `r` keeps continuity with ./r.
20
+ [project.scripts]
21
+ runner = "acsetra_cli.__main__:main"
22
+ acsetra = "acsetra_cli.__main__:main"
23
+ r = "acsetra_cli.__main__:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://acsetra.com"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["acsetra_cli"]