baudrier 0.2.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.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: baudrier
3
+ Version: 0.2.0
4
+ Summary: Baudrier CLI: connect projects to your AI coding harness and validate harness repositories
5
+ Project-URL: Homepage, https://jack-of-all-trades.dev
6
+ License: Baudrier CLI - Proprietary Licence (draft, to be reviewed)
7
+
8
+ Copyright (c) 2026 Jonathan Mondaut. All rights reserved.
9
+
10
+ 1. Grant. You may install and use this software (the "CLI") solely to access and
11
+ use the Baudrier service.
12
+
13
+ 2. Restrictions. You may not redistribute, sublicense, sell, resell or rent the CLI;
14
+ modify it for the purpose of redistribution; or reverse engineer, decompile or
15
+ attempt to extract the source or workings of the Baudrier service.
16
+
17
+ 3. Ownership. The CLI and the Baudrier service remain the property of the copyright
18
+ holder. No rights are granted except those stated above.
19
+
20
+ 4. No warranty. The CLI is provided "as is", without warranty of any kind, express
21
+ or implied, including merchantability, fitness for a particular purpose and
22
+ non-infringement.
23
+
24
+ 5. Limitation of liability. To the maximum extent permitted by law, the copyright
25
+ holder is not liable for any indirect, incidental or consequential damages, or
26
+ for any loss of data or profits, arising from the use of the CLI. Total
27
+ liability is limited to the amount you paid for the Baudrier service in the
28
+ twelve months before the claim (zero for free beta use).
29
+
30
+ 6. Termination. This licence ends automatically if you breach it.
31
+
32
+ 7. Governing law. This licence is governed by French law. Courts of France have
33
+ jurisdiction, subject to any mandatory consumer protection rules.
34
+ License-File: LICENSE
35
+ Classifier: Environment :: Console
36
+ Classifier: License :: Other/Proprietary License
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Requires-Python: >=3.11
41
+ Requires-Dist: fastmcp<5,>=4.0
42
+ Requires-Dist: jsonschema>=4.21
43
+ Requires-Dist: pydantic>=2.7
44
+ Requires-Dist: pyyaml>=6.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest>=8; extra == 'dev'
47
+ Provides-Extra: verify
48
+ Description-Content-Type: text/markdown
49
+
50
+ # baudrier
51
+
52
+ The command-line client for [Baudrier](https://jack-of-all-trades.dev): it connects a project folder to your
53
+ Baudrier harness so your AI coding agents (Claude Code, Codex, Gemini CLI, Copilot) get the right instructions
54
+ and skills, and it can validate harness repositories.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ uv tool install baudrier
60
+ ```
61
+
62
+ ## Quick start
63
+
64
+ ```bash
65
+ baudrier login --url https://api.<domain> # sign in with your browser
66
+ baudrier init --slug <slug> # connect this project folder
67
+ baudrier validate . # validate a harness repository
68
+ ```
69
+
70
+ `baudrier login` has no default server: the URL comes from your invite or the web app.
71
+
72
+ Run `baudrier --help` for all commands. Documentation: https://jack-of-all-trades.dev
73
+
74
+ ## Licence
75
+
76
+ Proprietary, all rights reserved. See `LICENSE`. Use is permitted to access the Baudrier service.
@@ -0,0 +1,23 @@
1
+ baudrier_core/__init__.py,sha256=-MXeWkg778ez8UTMzAJFQ6MR7lD_6QcyuhfKVpOX6QI,488
2
+ baudrier_core/account.py,sha256=eZGXPzbD4-3kT3IGboh2hmkHQY6O29Uv8f6U9QoBNC0,9672
3
+ baudrier_core/cli.py,sha256=JP1RfdsEYLOb-U-DlqhPc0P71ls85pjTno0p8uOUojQ,23636
4
+ baudrier_core/credentials.py,sha256=CV_mBocftr7bBf1FdFXIWDdPslDKdiS4Ipcw-gPyW7E,5546
5
+ baudrier_core/errors.py,sha256=HKYAxzpV-ZRce88lrcLZA_vN4j7X8B0OIcLPj_UB9ms,199
6
+ baudrier_core/hashing.py,sha256=vuto9RGEsp2lxTBfSt5dafg540WN1tKSRBjM-7s0UTI,1341
7
+ baudrier_core/import_cmd.py,sha256=7YyIIAjJl5KGgvUm_tm7AGsSgT-pWK_Rya9ANR6-Sms,23474
8
+ baudrier_core/init_cmd.py,sha256=0YgJ_HcMERZfb7fdm4MtP_SDsC2JN-akZJxjasQmkFM,23914
9
+ baudrier_core/mcp_proxy.py,sha256=gN16zwC2Ibj91c7RaAECkq235ZF9MA5Xhdz60fbEUGc,4475
10
+ baudrier_core/models.py,sha256=pBU97x5dLz01fpiMThqm8V9596IebDh4ye1IS87-GW4,772
11
+ baudrier_core/project_ref.py,sha256=OxJTeodHcIUxd-2j26oHAHhKHmW0KUGwplAtOE06o2I,3298
12
+ baudrier_core/promote_cmd.py,sha256=q2w51QLOQMLr_h8bfufBDTLuMzYCAUhAahsmhoI1nOM,4655
13
+ baudrier_core/resolver.py,sha256=7cv9fNaf8pRgBDfZE7U26nvW2oC1ulGCSI9JQGKtO_c,9773
14
+ baudrier_core/secrets_scan.py,sha256=AjW_ijGkPKORkMV8ce13eB6CWi0zTOjMbOSc853EVwU,1012
15
+ baudrier_core/sync_cmd.py,sha256=VggX-H8vK65TzdmMOFo2AwEnxuVhT--N0HivPLuoumw,18030
16
+ baudrier_core/token_errors.py,sha256=DcIw5zbpTErzDXpAVPUstcOehji1kPnUNDvuVU06rbA,3578
17
+ baudrier_core/validate.py,sha256=gektE7E-054fOGYpIAuLlhGgCbvlMWUGB2OUvcxgs6Y,1565
18
+ baudrier_core/schema/harness.schema.json,sha256=ejR8NtYHk0ec9frSjniVCrj_FNcGP9IDeHE--wBvGXk,32027
19
+ baudrier-0.2.0.dist-info/METADATA,sha256=jMkraWJRceKRZDA8pkSIhbrFGNDBFhWkiWbN5tSydeU,3199
20
+ baudrier-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
+ baudrier-0.2.0.dist-info/entry_points.txt,sha256=NMuXUHEV0_2h35lOgMoVCfqDZ5F7ZkjIMrtT8O-xe68,52
22
+ baudrier-0.2.0.dist-info/licenses/LICENSE,sha256=pSi_04pJnjiMkeFLf1gMdQwnSsaIDe6fx7yYQ0Hi00Q,1392
23
+ baudrier-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ baudrier = baudrier_core.cli:main
@@ -0,0 +1,28 @@
1
+ Baudrier CLI - Proprietary Licence (draft, to be reviewed)
2
+
3
+ Copyright (c) 2026 Jonathan Mondaut. All rights reserved.
4
+
5
+ 1. Grant. You may install and use this software (the "CLI") solely to access and
6
+ use the Baudrier service.
7
+
8
+ 2. Restrictions. You may not redistribute, sublicense, sell, resell or rent the CLI;
9
+ modify it for the purpose of redistribution; or reverse engineer, decompile or
10
+ attempt to extract the source or workings of the Baudrier service.
11
+
12
+ 3. Ownership. The CLI and the Baudrier service remain the property of the copyright
13
+ holder. No rights are granted except those stated above.
14
+
15
+ 4. No warranty. The CLI is provided "as is", without warranty of any kind, express
16
+ or implied, including merchantability, fitness for a particular purpose and
17
+ non-infringement.
18
+
19
+ 5. Limitation of liability. To the maximum extent permitted by law, the copyright
20
+ holder is not liable for any indirect, incidental or consequential damages, or
21
+ for any loss of data or profits, arising from the use of the CLI. Total
22
+ liability is limited to the amount you paid for the Baudrier service in the
23
+ twelve months before the claim (zero for free beta use).
24
+
25
+ 6. Termination. This licence ends automatically if you breach it.
26
+
27
+ 7. Governing law. This licence is governed by French law. Courts of France have
28
+ jurisdiction, subject to any mandatory consumer protection rules.
@@ -0,0 +1,9 @@
1
+ from baudrier_core.errors import BaudrierError, HarnessResolutionError, HarnessValidationError
2
+ from baudrier_core.hashing import tree_hash
3
+ from baudrier_core.models import ResolvedHarness, ResolvedSkill
4
+ from baudrier_core.resolver import load_harness, resolve_harness_at, validate_harness
5
+
6
+ __all__ = [
7
+ "BaudrierError", "HarnessResolutionError", "HarnessValidationError", "ResolvedHarness",
8
+ "ResolvedSkill", "load_harness", "resolve_harness_at", "tree_hash", "validate_harness",
9
+ ]
@@ -0,0 +1,234 @@
1
+ """The CLI's REST client for the account routes (stdlib only: no new dependency)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import socket
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ import webbrowser
11
+
12
+ from baudrier_core import credentials
13
+ from baudrier_core.errors import BaudrierError
14
+
15
+ TIMEOUT_S = 15
16
+
17
+
18
+ class AccountError(BaudrierError):
19
+ pass
20
+
21
+
22
+ class NotLoggedIn(AccountError):
23
+ pass
24
+
25
+
26
+ def default_label() -> str:
27
+ return f"{socket.gethostname().split('.')[0]} cli"[:100]
28
+
29
+
30
+ def clean_label(text: str | None) -> str:
31
+ cleaned = "".join(c for c in (text or "") if c.isprintable()).strip()[:100].strip()
32
+ return cleaned or default_label()
33
+
34
+
35
+ def _scrub(text: str, token: str | None) -> str:
36
+ return text.replace(token, "***") if token else text
37
+
38
+
39
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
40
+ """Never follow a redirect: urllib would resend the Authorization header to whatever host it names."""
41
+
42
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: PLR0913 (urllib's signature)
43
+ return None
44
+
45
+
46
+ _OPENER = urllib.request.build_opener(_NoRedirect)
47
+
48
+
49
+ def _open(req: urllib.request.Request, timeout: float):
50
+ return _OPENER.open(req, timeout=timeout)
51
+
52
+
53
+ def _need(data: object, *keys: str) -> dict:
54
+ """The JSON object with these keys, or an AccountError (never a KeyError) when the server sent something else."""
55
+ if not isinstance(data, dict) or any(k not in data for k in keys):
56
+ raise AccountError(f"unexpected answer from the server (missing {', '.join(keys)})")
57
+ return data
58
+
59
+
60
+ def request(method: str, url: str, *, token: str | None = None, body: object = None,
61
+ headers: dict | None = None, timeout: float | None = None) -> tuple[int, object]:
62
+ data = json.dumps(body).encode() if body is not None else None
63
+ req = urllib.request.Request(url, data=data, method=method)
64
+ req.add_header("Accept", "application/json")
65
+ if data is not None:
66
+ req.add_header("Content-Type", "application/json")
67
+ if token:
68
+ req.add_header("Authorization", f"Bearer {token}")
69
+ for k, v in (headers or {}).items():
70
+ req.add_header(k, v)
71
+ try:
72
+ with _open(req, TIMEOUT_S if timeout is None else timeout) as resp:
73
+ raw = resp.read()
74
+ return resp.status, (json.loads(raw) if raw else None)
75
+ except urllib.error.HTTPError as exc:
76
+ if 300 <= exc.code < 400:
77
+ location = _scrub(str(exc.headers.get("Location", "")), token)
78
+ raise AccountError(f"the server redirected to {location}; use that as --url") from None
79
+ try:
80
+ return exc.code, json.loads(exc.read() or b"null")
81
+ except ValueError:
82
+ return exc.code, None
83
+ except (urllib.error.URLError, OSError, ValueError) as exc:
84
+ reason = getattr(exc, "reason", exc)
85
+ raise AccountError(_scrub(f"cannot reach {url.split('/api/')[0]}: {reason}", token)) from None
86
+
87
+
88
+ def _detail(data: object, status: int) -> str:
89
+ d = data.get("detail") if isinstance(data, dict) else None
90
+ if isinstance(d, list):
91
+ d = "; ".join(str(x.get("msg", x)) if isinstance(x, dict) else str(x) for x in d)
92
+ return str(d or f"HTTP {status}")
93
+
94
+
95
+ def call(base: str, method: str, path: str, *, token: str, body: object = None) -> object:
96
+ status, data = request(method, credentials.norm_url(base) + path, token=token, body=body)
97
+ if status == 401:
98
+ raise NotLoggedIn("not logged in, or your login expired: run `baudrier login`")
99
+ if status >= 400:
100
+ raise AccountError(_scrub(_detail(data, status), token))
101
+ return data
102
+
103
+
104
+ def _say(text: str) -> None:
105
+ print(text, flush=True) # the code must show at once even when stdout is a pipe or a file (no TTY)
106
+
107
+
108
+ def login(base: str, label: str, *, open_browser: bool = True, out=_say, sleep=time.sleep,
109
+ clock=time.monotonic) -> dict:
110
+ base = credentials.norm_url(base)
111
+ status, d = request("POST", f"{base}/api/cli/device", body={"label": label})
112
+ if status != 200 or not isinstance(d, dict):
113
+ raise AccountError(f"could not start the login: {_detail(d, status)}")
114
+ _need(d, "device_code", "user_code", "verification_uri", "verification_uri_complete")
115
+ out(f"Open {d['verification_uri']} and check that it shows the code {d['user_code']}")
116
+ if open_browser:
117
+ try:
118
+ webbrowser.open(d["verification_uri_complete"])
119
+ except webbrowser.Error:
120
+ pass
121
+ interval = int(d.get("interval", 5))
122
+ deadline = clock() + int(d.get("expires_in", 600))
123
+ while clock() < deadline:
124
+ sleep(interval)
125
+ status, r = request("POST", f"{base}/api/cli/device/token", body={"device_code": d["device_code"]})
126
+ if status == 429:
127
+ interval += 5
128
+ continue
129
+ if status >= 400 or not isinstance(r, dict):
130
+ raise AccountError(f"login failed: {_detail(r, status)}")
131
+ if r.get("status") == "approved":
132
+ _need(r, "token", "login", "expires_at")
133
+ credentials.set_account(base, r["token"], r["login"], r["expires_at"])
134
+ return {"login": r["login"], "expires_at": r["expires_at"]}
135
+ if r.get("status") == "denied":
136
+ raise AccountError("the login was denied in the browser")
137
+ if r.get("status") == "expired":
138
+ break
139
+ raise AccountError("the code expired; run `baudrier login` again")
140
+
141
+
142
+ def logout(base: str, projects: bool = False) -> None:
143
+ acc = credentials.account(base)
144
+ if acc and acc.get("token"):
145
+ try:
146
+ request("DELETE", credentials.norm_url(base) + "/api/account/tokens/current", token=acc["token"])
147
+ except AccountError:
148
+ pass # best effort: forget it locally anyway
149
+ credentials.clear_account(base, projects=projects)
150
+
151
+
152
+ def whoami(base: str) -> dict:
153
+ token = credentials.account_token(base)
154
+ if not token:
155
+ raise NotLoggedIn("not logged in, or your login expired: run `baudrier login`")
156
+ me = _need(call(base, "GET", "/api/auth/me", token=token), "login")
157
+ acc = credentials.account(base) or {}
158
+ return {"login": me["login"], "url": credentials.norm_url(base), "expires_at": acc.get("expires_at", "")}
159
+
160
+
161
+ _REPO_REF = re.compile(
162
+ r"^(?:https?://)?github\.com/(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?"
163
+ r"(?:/(?P<path>[^@#]+?))?/?(?:@(?P<ref>[^#@]+))?(?:#(?P<name>[^#]+))?$")
164
+ _NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$")
165
+
166
+
167
+ def parse_harness_ref(ref: str) -> dict:
168
+ ref = ref.strip()
169
+ m = _REPO_REF.match(ref)
170
+ if m:
171
+ return {"kind": "repo", "repo": m["repo"], "path": m["path"], "ref": m["ref"], "name": m["name"]}
172
+ if "/" not in ref and _NAME.match(ref):
173
+ return {"kind": "name", "name": ref}
174
+ raise AccountError(f"{ref!r} is neither a harness name nor github.com/<owner>/<repo>[/<path>][@<ref>][#<name>]")
175
+
176
+
177
+ def find_project(base: str, token: str, slug: str) -> dict:
178
+ for p in call(base, "GET", "/api/projects", token=token):
179
+ if p["slug"] == slug:
180
+ return p
181
+ raise AccountError(f"no project {slug!r} in your account; create it with `baudrier project create`")
182
+
183
+
184
+ def create_project(base: str, token: str, name: str, slug: str | None) -> dict:
185
+ return call(base, "POST", "/api/projects", token=token, body={"name": name, "slug": slug})
186
+
187
+
188
+ def _pick(base: str, token: str, ref: dict) -> dict:
189
+ if ref["kind"] == "name":
190
+ matches = [h for h in call(base, "GET", "/api/harnesses", token=token) if h["name"] == ref["name"]]
191
+ if not matches:
192
+ raise AccountError(f"no harness named {ref['name']!r} in your Library; add its repo with "
193
+ "`baudrier add harness github.com/<owner>/<repo>`")
194
+ if len(matches) > 1:
195
+ where = ", ".join(f"{h['repo']}/{h['path']}".rstrip("/") for h in matches)
196
+ raise AccountError(f"several harnesses are named {ref['name']!r} ({where}); use "
197
+ f"github.com/<owner>/<repo>#{ref['name']} to choose")
198
+ return matches[0]
199
+ body = {"repo": ref["repo"], "ref": ref["ref"] or "main"}
200
+ if ref["path"]:
201
+ body["path"] = ref["path"]
202
+ found = call(base, "POST", "/api/sources", token=token, body=body)
203
+ if ref["name"]:
204
+ found = [h for h in found if h["name"] == ref["name"]]
205
+ if not found:
206
+ raise AccountError(f"{ref['repo']} has no harness named {ref['name']!r}")
207
+ if len(found) > 1:
208
+ names = ", ".join(h["name"] for h in found)
209
+ raise AccountError(f"{ref['repo']} has several harnesses ({names}); pick one with #<name>")
210
+ return found[0]
211
+
212
+
213
+ def add_harnesses(base: str, token: str, slug: str, refs: list[str]) -> list[str]:
214
+ parsed = [parse_harness_ref(r) for r in refs] # all refs are checked before anything is attached
215
+ project = find_project(base, token, slug)
216
+ names = []
217
+ for ref in parsed:
218
+ h = _pick(base, token, ref)
219
+ call(base, "POST", f"/api/projects/{project['id']}/harnesses", token=token, body={"harness_id": h["id"]})
220
+ names.append(h["name"])
221
+ return names
222
+
223
+
224
+ def ensure_project_token(base: str, slug: str, label: str) -> bool:
225
+ if credentials.project_token(base, slug):
226
+ return False
227
+ token = credentials.account_token(base)
228
+ if not token:
229
+ raise NotLoggedIn("no token for this project: run `baudrier login` first (or set BAUDRIER_TOKEN)")
230
+ project = find_project(base, token, slug)
231
+ created = _need(call(base, "POST", f"/api/projects/{project['id']}/tokens", token=token, body={"label": label}),
232
+ "token")
233
+ credentials.set_project_token(base, slug, created["token"])
234
+ return True