caurad 0.10.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ # Go build artifacts
2
+ *.exe
3
+ *.exe~
4
+ *.dll
5
+ *.so
6
+ *.dylib
7
+ *.test
8
+ *.out
9
+ vendor/
10
+
11
+ # Coverage and profiling
12
+ *.coverprofile
13
+ coverage.txt
14
+ *.prof
15
+ cover.out
16
+
17
+ # Editor / IDE
18
+ .vscode/
19
+ .idea/
20
+ *.swp
21
+ *.swo
22
+
23
+ # OS metadata
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Secrets and local config (no secrets in repo per Constitution)
28
+ .env
29
+ .env.*
30
+ *.local
31
+ /bin/
32
+
33
+ # Python bytecode (uvx wrapper py_compile)
34
+ __pycache__/
35
+ *.pyc
36
+ dist/
caurad-0.10.1/PKG-INFO ADDED
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: caurad
3
+ Version: 0.10.1
4
+ Summary: uvx/pip wrapper for the Caura broker: downloads the platform binary from the public release mirror, SHA-256-verifies it, and execs it.
5
+ Project-URL: Homepage, https://caura.ai
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # `caurad` — uvx / pip wrapper
11
+
12
+ Run the Caura broker from the Python ecosystem:
13
+
14
+ ```bash
15
+ uvx caurad --help # run without installing
16
+ pipx install caurad && caurad --help # or install the canonical entry point
17
+ ```
18
+
19
+ ## How it works
20
+
21
+ This package is a thin dispatcher — the broker itself is a single Go binary.
22
+
23
+ On first canonical or compatibility entry-point invocation,
24
+ `wrappers/uvx/caurad/cli.py` detects your
25
+ platform, downloads the matching signed archive from the public release mirror,
26
+ verifies its SHA-256 against the published `checksums.txt`, caches the binary
27
+ under `~/.cache/memclawd/<version>/`, and `os.execv`s it with your arguments —
28
+ so `uvx caurad <args>` behaves like the native `memclaw <args>`. Subsequent <!-- legacy-name-floor: installed entry point -->
29
+ invocations reuse the cached binary.
30
+
31
+ The install itself does NOT prefetch the binary — the download happens on first
32
+ invocation. That keeps the package tiny and works under pipx / sandboxed
33
+ installs where postinstall hooks don't fire.
34
+
35
+ The binary is downloaded from the public release mirror
36
+ (`https://memclaw.net/memclaw/<tag>/`) — the same source the
37
+ `wrappers/curl/install.sh` installer uses, not this repo's GitHub Releases
38
+ (which are private). SHA-256 verification is the integrity gate.
39
+
40
+ ## Environment overrides
41
+
42
+ Both are also read under their historical `MEMCLAW_*` spellings<!-- legacy-name-ok: rule 3 dual-read alias -->, which keep
43
+ working indefinitely; the `CAURA_*` name wins when it holds a non-empty value.
44
+
45
+ - `CAURA_BINARY_BASE` — base URL of a release mirror (default
46
+ `https://memclaw.net/memclaw`). Point it at an on-prem gateway's
47
+ `<origin>/memclaw` to install from a private deployment.
48
+ - `CAURA_VERSION` — pin a specific release tag instead of the installed
49
+ package's version.
50
+
51
+ The published package version is set from the release tag by the PyPI-publish
52
+ workflow; the `0.0.0` in `pyproject.toml` is a placeholder for local checkouts.
53
+
54
+ ## Compatibility name
55
+
56
+ Existing `pip install memclawd` and `uvx --from memclawd memclaw` commands keep working through <!-- legacy-name-ok: permanent PyPI compatibility alias -->
57
+ the separate compatibility package, which depends on `caurad`. New installation
58
+ instructions should use the canonical name above.
59
+
60
+ Licensed under Apache-2.0, matching the `LICENSE` at the repository root.
@@ -0,0 +1,51 @@
1
+ # `caurad` — uvx / pip wrapper
2
+
3
+ Run the Caura broker from the Python ecosystem:
4
+
5
+ ```bash
6
+ uvx caurad --help # run without installing
7
+ pipx install caurad && caurad --help # or install the canonical entry point
8
+ ```
9
+
10
+ ## How it works
11
+
12
+ This package is a thin dispatcher — the broker itself is a single Go binary.
13
+
14
+ On first canonical or compatibility entry-point invocation,
15
+ `wrappers/uvx/caurad/cli.py` detects your
16
+ platform, downloads the matching signed archive from the public release mirror,
17
+ verifies its SHA-256 against the published `checksums.txt`, caches the binary
18
+ under `~/.cache/memclawd/<version>/`, and `os.execv`s it with your arguments —
19
+ so `uvx caurad <args>` behaves like the native `memclaw <args>`. Subsequent <!-- legacy-name-floor: installed entry point -->
20
+ invocations reuse the cached binary.
21
+
22
+ The install itself does NOT prefetch the binary — the download happens on first
23
+ invocation. That keeps the package tiny and works under pipx / sandboxed
24
+ installs where postinstall hooks don't fire.
25
+
26
+ The binary is downloaded from the public release mirror
27
+ (`https://memclaw.net/memclaw/<tag>/`) — the same source the
28
+ `wrappers/curl/install.sh` installer uses, not this repo's GitHub Releases
29
+ (which are private). SHA-256 verification is the integrity gate.
30
+
31
+ ## Environment overrides
32
+
33
+ Both are also read under their historical `MEMCLAW_*` spellings<!-- legacy-name-ok: rule 3 dual-read alias -->, which keep
34
+ working indefinitely; the `CAURA_*` name wins when it holds a non-empty value.
35
+
36
+ - `CAURA_BINARY_BASE` — base URL of a release mirror (default
37
+ `https://memclaw.net/memclaw`). Point it at an on-prem gateway's
38
+ `<origin>/memclaw` to install from a private deployment.
39
+ - `CAURA_VERSION` — pin a specific release tag instead of the installed
40
+ package's version.
41
+
42
+ The published package version is set from the release tag by the PyPI-publish
43
+ workflow; the `0.0.0` in `pyproject.toml` is a placeholder for local checkouts.
44
+
45
+ ## Compatibility name
46
+
47
+ Existing `pip install memclawd` and `uvx --from memclawd memclaw` commands keep working through <!-- legacy-name-ok: permanent PyPI compatibility alias -->
48
+ the separate compatibility package, which depends on `caurad`. New installation
49
+ instructions should use the canonical name above.
50
+
51
+ Licensed under Apache-2.0, matching the `LICENSE` at the repository root.
@@ -0,0 +1 @@
1
+ """Python wrapper for downloading and dispatching the Caura broker."""
@@ -0,0 +1,273 @@
1
+ """Caura broker — uvx/pip wrapper entry point.
2
+
3
+ On first invocation, downloads the platform-specific signed binary
4
+ from the public release mirror, verifies SHA-256 against the
5
+ published checksums, caches the binary under
6
+ ``~/.cache/memclawd/<version>/``, and execs it with forwarded args.
7
+ Subsequent invocations reuse the cached binary.
8
+
9
+ The pip / uvx install does NOT prefetch the binary — fetching
10
+ happens on first ``memclaw`` invocation. This keeps the Python
11
+ package small (just this dispatcher) and works under pipx /
12
+ sandboxes where postinstall hooks aren't fired.
13
+
14
+ What this script does NOT do:
15
+ - Auto-start the daemon (operator runs ``memclaw start``).
16
+ - Modify shell rc files.
17
+ - Verify GPG signatures (T118 is the additive layer; SHA-256 is
18
+ baseline).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import importlib.metadata as md
25
+ import os
26
+ import platform
27
+ import shutil
28
+ import sys
29
+ import tarfile
30
+ import tempfile
31
+ import urllib.request
32
+ import zipfile
33
+ from pathlib import Path
34
+ from urllib.parse import urlparse
35
+
36
+ # Both spellings, CAURA_* first. Resolution below is first NON-EMPTY,
37
+ # not first defined: an operator whose environment carries a blank
38
+ # CAURA_BINARY_BASE beside a working old-spelling one would otherwise
39
+ # silently fetch from the public mirror instead of their private
40
+ # deployment. Same contract as wrappers/curl/install.sh and the npx
41
+ # wrapper — one name set, every entry point honors it.
42
+ VERSION_ENV_CAURA = "CAURA_VERSION"
43
+ VERSION_ENV = "MEMCLAW_VERSION" # legacy-name-ok: rule 3 dual-read alias
44
+ BINARY_BASE_ENV_CAURA = "CAURA_BINARY_BASE"
45
+ BINARY_BASE_ENV = "MEMCLAW_BINARY_BASE" # legacy-name-ok: rule 3 dual-read alias
46
+ # Binaries come from the public release mirror, NOT this repo's GitHub
47
+ # Releases: the repo is private, so unauthenticated downloads 404. The
48
+ # mirror layout matches wrappers/curl/install.sh — <base>/<tag>/<archive>
49
+ # — and is owned by publish-mirror.yml. On-prem gateways serve their
50
+ # own mirror at <origin>/memclaw # legacy-name-floor: the served mirror path
51
+ # — set CAURA_BINARY_BASE to install from a private deployment.
52
+ DEFAULT_BINARY_BASE = "https://memclaw.net/memclaw"
53
+
54
+
55
+ def die(msg: str) -> int:
56
+ print(f"memclaw: {msg}", file=sys.stderr)
57
+ return 1
58
+
59
+
60
+ def package_version() -> str:
61
+ """Return the broker version this wrapper expects to dispatch.
62
+
63
+ Sourced from the installed package metadata (which the release
64
+ pipeline rewrites per release) unless CAURA_VERSION (or its
65
+ historical spelling) is set. Strips a leading 'v' so the GitHub
66
+ Release tag form converts cleanly to the goreleaser archive's
67
+ version segment.
68
+ """
69
+ if v := _resolve(VERSION_ENV_CAURA, VERSION_ENV)[0]:
70
+ return v.lstrip("v")
71
+ try:
72
+ v = md.version("caurad")
73
+ return v.lstrip("v")
74
+ except md.PackageNotFoundError:
75
+ return "0.0.0-dev"
76
+
77
+
78
+ def detect_platform() -> tuple[str, str, bool]:
79
+ """Return (os, arch, exe) tuple matching the goreleaser archive
80
+ naming. ``exe`` is True only on Windows.
81
+ """
82
+ system = platform.system().lower()
83
+ machine = platform.machine().lower()
84
+ if machine in ("x86_64", "amd64"):
85
+ arch = "x86_64"
86
+ elif machine in ("arm64", "aarch64"):
87
+ arch = "arm64"
88
+ else:
89
+ raise RuntimeError(f"unsupported arch: {machine}")
90
+ if system == "darwin":
91
+ return "darwin", arch, False
92
+ if system == "linux":
93
+ return "linux", arch, False
94
+ if system.startswith(("win", "msys", "cygwin")):
95
+ return "windows", arch, True
96
+ raise RuntimeError(f"unsupported OS: {system}")
97
+
98
+
99
+ def archive_url(base: str, version: str, os_name: str, arch: str, exe: bool) -> str:
100
+ """Construct the mirror archive URL: ``<base>/<tag>/<archive>``.
101
+
102
+ The tag directory carries a leading "v" (``version`` is stripped of
103
+ it); archive names do not. Names follow the goreleaser template.
104
+ """
105
+ ext = "zip" if exe else "tar.gz"
106
+ return f"{base}/v{version}/memclaw_{version}_{os_name}_{arch}.{ext}"
107
+
108
+
109
+ def checksums_url(base: str, version: str) -> str:
110
+ return f"{base}/v{version}/checksums.txt"
111
+
112
+
113
+ def _resolve(caura: str, legacy: str, default: str = "") -> tuple[str, str]:
114
+ """The first of the two names holding a NON-EMPTY value, and its name.
115
+
116
+ Returning the name is the same reason internal/envname.Lookup does: a
117
+ message naming the variable the operator actually set is actionable, and
118
+ one naming the other spelling sends them to edit something they never
119
+ wrote. Falls back to `default` under `caura`'s name, which is the one to
120
+ teach when neither was set.
121
+ """
122
+ for name in (caura, legacy):
123
+ if v := os.environ.get(name):
124
+ return v, name
125
+ return default, caura
126
+
127
+
128
+ def _validate_base_url(base: str, name: str) -> None:
129
+ """Require an https:// binary base.
130
+
131
+ urllib.request.urlopen honors file://, ftp://, plain http://, etc.
132
+ The archive and its checksums.txt are fetched from the same base, so
133
+ the SHA-256 check only detects tampering when TLS authenticates the
134
+ origin — plain http:// would let a network attacker swap both. https
135
+ also matches the npm wrapper (Node's https.get) and the wrapper's
136
+ original always-https download path.
137
+ """
138
+ scheme = urlparse(base).scheme
139
+ if scheme != "https":
140
+ raise RuntimeError(
141
+ f"{name} must be https, got {scheme!r} in {base!r}"
142
+ )
143
+
144
+
145
+ def download(url: str, dest: Path) -> None:
146
+ """Stream `url` to `dest`. Raises on non-200."""
147
+ with urllib.request.urlopen(url) as resp: # noqa: S310 — scheme validated (https) in ensure_binary
148
+ if resp.status != 200:
149
+ raise RuntimeError(f"HTTP {resp.status} for {url}")
150
+ with dest.open("wb") as f:
151
+ shutil.copyfileobj(resp, f)
152
+
153
+
154
+ def fetch_text(url: str) -> str:
155
+ with urllib.request.urlopen(url) as resp: # noqa: S310 — scheme validated (https) in ensure_binary
156
+ if resp.status != 200:
157
+ raise RuntimeError(f"HTTP {resp.status} for {url}")
158
+ return resp.read().decode("utf-8")
159
+
160
+
161
+ def sha256_of(path: Path) -> str:
162
+ h = hashlib.sha256()
163
+ with path.open("rb") as f:
164
+ for chunk in iter(lambda: f.read(1 << 20), b""):
165
+ h.update(chunk)
166
+ return h.hexdigest()
167
+
168
+
169
+ def cache_dir(version: str) -> Path:
170
+ base = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")))
171
+ return base / "memclawd" / version
172
+
173
+
174
+ def _extract_tar_safely(tf: tarfile.TarFile, dest: Path) -> None:
175
+ """Extract ``tf`` into ``dest``, rejecting members that escape ``dest``.
176
+
177
+ Prefers the stdlib data filter, but the ``filter=`` keyword only exists
178
+ on Python 3.12+ (backported to 3.11.4, alongside ``tarfile.data_filter``
179
+ — so ``hasattr`` feature-detects it exactly). On older interpreters —
180
+ e.g. stock Debian 12 ships 3.11.2 — passing it raises ``TypeError`` and
181
+ the wrapper never installs its binary. There, hand-check each member:
182
+ allow only regular files and directories whose resolved path stays
183
+ inside ``dest`` (rejecting absolute paths, ``..`` traversal, and
184
+ symlink/device members). That is a narrower guard than ``data_filter``,
185
+ which is fine here — the archive is a SHA-256-verified, single-binary
186
+ goreleaser tarball, not arbitrary input.
187
+ """
188
+ if hasattr(tarfile, "data_filter"):
189
+ tf.extractall(dest, filter="data")
190
+ return
191
+ dest_resolved = dest.resolve()
192
+ for member in tf.getmembers():
193
+ if not (member.isfile() or member.isdir()):
194
+ raise RuntimeError(f"non-regular member in tar archive: {member.name!r}")
195
+ if not (dest / member.name).resolve().is_relative_to(dest_resolved):
196
+ raise RuntimeError(f"unsafe path in tar archive: {member.name!r}")
197
+ tf.extractall(dest) # members validated above
198
+
199
+
200
+ def ensure_binary(version: str) -> Path:
201
+ """Return the absolute path to the cached binary, downloading
202
+ if necessary. Raises on any integrity failure.
203
+ """
204
+ os_name, arch, exe = detect_platform()
205
+ base, base_name = _resolve(
206
+ BINARY_BASE_ENV_CAURA, BINARY_BASE_ENV, DEFAULT_BINARY_BASE
207
+ )
208
+ base = base.rstrip("/")
209
+ _validate_base_url(base, base_name)
210
+ bin_name = "memclaw.exe" if exe else "memclaw"
211
+ cache = cache_dir(version)
212
+ bin_path = cache / bin_name
213
+ if bin_path.exists():
214
+ return bin_path
215
+
216
+ cache.mkdir(parents=True, exist_ok=True)
217
+ arch_name = f"memclaw_{version}_{os_name}_{arch}.{'zip' if exe else 'tar.gz'}"
218
+ archive_url_full = archive_url(base, version, os_name, arch, exe)
219
+
220
+ print(f"memclaw: downloading {archive_url_full}", file=sys.stderr)
221
+ with tempfile.TemporaryDirectory() as tmp:
222
+ tmp_dir = Path(tmp)
223
+ archive_path = tmp_dir / arch_name
224
+ download(archive_url_full, archive_path)
225
+
226
+ # Verify SHA-256.
227
+ sums = fetch_text(checksums_url(base, version))
228
+ expected = None
229
+ for line in sums.splitlines():
230
+ parts = line.split()
231
+ if len(parts) >= 2 and parts[-1] == arch_name:
232
+ expected = parts[0]
233
+ break
234
+ if expected is None:
235
+ raise RuntimeError(f"no checksum entry for {arch_name}")
236
+ actual = sha256_of(archive_path)
237
+ if actual != expected:
238
+ raise RuntimeError(
239
+ f"checksum mismatch for {arch_name}: expected={expected} actual={actual}"
240
+ )
241
+
242
+ # Extract.
243
+ if exe:
244
+ with zipfile.ZipFile(archive_path) as zf:
245
+ zf.extractall(tmp_dir)
246
+ else:
247
+ with tarfile.open(archive_path, "r:gz") as tf:
248
+ # Safe extract, guarded for Pythons without the filter kwarg.
249
+ _extract_tar_safely(tf, tmp_dir)
250
+ src = tmp_dir / bin_name
251
+ if not src.exists():
252
+ raise RuntimeError(f"archive did not contain {bin_name}")
253
+ shutil.move(str(src), str(bin_path))
254
+ if not exe:
255
+ bin_path.chmod(0o755)
256
+ return bin_path
257
+
258
+
259
+ def main() -> int:
260
+ try:
261
+ version = package_version()
262
+ bin_path = ensure_binary(version)
263
+ except (RuntimeError, OSError) as err:
264
+ return die(str(err))
265
+ # os.execv replaces the Python process with the binary, so
266
+ # stdout/stderr/stdin/signals all flow naturally. argv[0] is
267
+ # the binary's own name to match what `exec memclaw ...`
268
+ # would produce.
269
+ os.execv(str(bin_path), [str(bin_path), *sys.argv[1:]])
270
+
271
+
272
+ if __name__ == "__main__":
273
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "caurad"
3
+ version = "0.10.1"
4
+ description = "uvx/pip wrapper for the Caura broker: downloads the platform binary from the public release mirror, SHA-256-verifies it, and execs it."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+
9
+ [project.urls]
10
+ Homepage = "https://caura.ai"
11
+
12
+ [project.scripts]
13
+ caurad = "caurad.cli:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling>=1.27"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["caurad"]