yoru-cli 0.2.0__tar.gz → 0.3.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.
Files changed (58) hide show
  1. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/.gitignore +3 -0
  2. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/PKG-INFO +13 -2
  3. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/README.md +9 -0
  4. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/pyproject.toml +13 -3
  5. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/api.py +32 -2
  6. yoru_cli-0.3.0/src/yoru_cli/cli.py +260 -0
  7. yoru_cli-0.3.0/src/yoru_cli/config.py +297 -0
  8. yoru_cli-0.3.0/src/yoru_cli/doctor_cmd.py +164 -0
  9. yoru_cli-0.3.0/src/yoru_cli/dsse_verify.py +183 -0
  10. yoru_cli-0.3.0/src/yoru_cli/enforce_cmd.py +223 -0
  11. yoru_cli-0.3.0/src/yoru_cli/enforce_policy.py +107 -0
  12. yoru_cli-0.3.0/src/yoru_cli/enforce_template.py +34 -0
  13. yoru_cli-0.3.0/src/yoru_cli/git_hook_run.py +212 -0
  14. yoru_cli-0.3.0/src/yoru_cli/git_hooks.py +106 -0
  15. yoru_cli-0.3.0/src/yoru_cli/git_reconcile.py +210 -0
  16. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/hook_template.py +14 -3
  17. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/init_cmd.py +120 -12
  18. yoru_cli-0.3.0/src/yoru_cli/logout_cmd.py +48 -0
  19. yoru_cli-0.3.0/src/yoru_cli/rotate_cmd.py +75 -0
  20. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/skill_template.py +2 -2
  21. yoru_cli-0.3.0/src/yoru_cli/transcript_tailer.py +820 -0
  22. yoru_cli-0.3.0/src/yoru_cli/use_cmd.py +44 -0
  23. yoru_cli-0.3.0/src/yoru_cli/verify_export_cmd.py +95 -0
  24. yoru_cli-0.3.0/tests/test_cli.py +89 -0
  25. yoru_cli-0.3.0/tests/test_config.py +155 -0
  26. yoru_cli-0.3.0/tests/test_doctor.py +181 -0
  27. yoru_cli-0.3.0/tests/test_enforce.py +247 -0
  28. yoru_cli-0.3.0/tests/test_enforce_hook_script.py +93 -0
  29. yoru_cli-0.3.0/tests/test_git_hook_run.py +207 -0
  30. yoru_cli-0.3.0/tests/test_git_hooks.py +81 -0
  31. yoru_cli-0.3.0/tests/test_git_reconcile.py +400 -0
  32. yoru_cli-0.3.0/tests/test_hook_template.py +122 -0
  33. yoru_cli-0.3.0/tests/test_init.py +440 -0
  34. yoru_cli-0.3.0/tests/test_logout.py +86 -0
  35. yoru_cli-0.3.0/tests/test_rotate.py +126 -0
  36. yoru_cli-0.3.0/tests/test_transcript_tailer.py +565 -0
  37. yoru_cli-0.3.0/tests/test_use.py +84 -0
  38. yoru_cli-0.3.0/tests/test_verify_export.py +160 -0
  39. yoru_cli-0.3.0/uv.lock +577 -0
  40. yoru_cli-0.2.0/src/yoru_cli/cli.py +0 -137
  41. yoru_cli-0.2.0/src/yoru_cli/config.py +0 -43
  42. yoru_cli-0.2.0/src/yoru_cli/doctor_cmd.py +0 -93
  43. yoru_cli-0.2.0/src/yoru_cli/transcript_tailer.py +0 -485
  44. yoru_cli-0.2.0/tests/test_cli.py +0 -38
  45. yoru_cli-0.2.0/tests/test_doctor.py +0 -34
  46. yoru_cli-0.2.0/tests/test_hook_template.py +0 -25
  47. yoru_cli-0.2.0/tests/test_init.py +0 -161
  48. yoru_cli-0.2.0/uv.lock +0 -230
  49. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/LICENSE +0 -0
  50. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/__init__.py +0 -0
  51. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/__main__.py +0 -0
  52. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/share_cmd.py +0 -0
  53. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/tail_cmd.py +0 -0
  54. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/src/yoru_cli/update_cmd.py +0 -0
  55. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/tests/__init__.py +0 -0
  56. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/tests/test_init_settings_merge.py +0 -0
  57. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/tests/test_tail.py +0 -0
  58. {yoru_cli-0.2.0 → yoru_cli-0.3.0}/tests/test_update.py +0 -0
@@ -80,3 +80,6 @@ backend/data/.auth_jwt_secret
80
80
 
81
81
  # install-smoke pre-release wheel (transient)
82
82
  install-smoke/_wheel/
83
+
84
+ # Local MCP wiring (per-agent trovex tokens etc.) — never track; edits must not dirty the merge target
85
+ .mcp.json
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: yoru-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Yoru — audit-grade session receipts for autonomous AI coding agents.
5
5
  Project-URL: Homepage, https://yoru.sh
6
6
  Project-URL: Documentation, https://yoru.sh/docs
@@ -19,9 +19,11 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Requires-Python: >=3.10
22
+ Requires-Dist: cryptography>=42
22
23
  Requires-Dist: httpx>=0.27
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest>=7; extra == 'dev'
26
+ Requires-Dist: yoru-contract; extra == 'dev'
25
27
  Description-Content-Type: text/markdown
26
28
 
27
29
  # yoru-cli
@@ -70,6 +72,15 @@ The installed script is ~20 lines of Bash that `POST`s one JSON event per Claude
70
72
 
71
73
  It uses `curl --max-time 2 || true` so a Yoru outage never stalls your terminal — Claude Code doesn't notice the hook is even there.
72
74
 
75
+ ## Multiple devs, multiple identities
76
+
77
+ Each paired identity (server + token) gets its own local slot under `~/.config/yoru/identities/`. The shared tailer and every hook event attribute to whichever identity is **active** — there is no per-process attribution.
78
+
79
+ - **One dev per machine (the normal case):** no ambiguity — one OS user, one identity, all activity is unambiguously yours.
80
+ - **Same OS login, multiple identities paired** (e.g. you re-paired against a second org, or a shared machine): only one identity is active at a time. Concurrent work under that same login attributes to whichever identity is active *regardless of who actually ran it* — this is an explicit, documented limit, not a bug. Switch identities first with `yoru use <label>`.
81
+
82
+ Run `yoru doctor` any time to see the active identity and get warned if other paired identities exist unused on this machine.
83
+
73
84
  ## Self-host the server
74
85
 
75
86
  The CLI is MIT. The server (backend + dashboard) is AGPL-3.0 and lives at **[github.com/TsukumoHQ/yoru](https://github.com/TsukumoHQ/yoru)**. `docker-compose up`, then `yoru init --server https://your-host` — done. There is no hosted Yoru; you run it.
@@ -44,6 +44,15 @@ The installed script is ~20 lines of Bash that `POST`s one JSON event per Claude
44
44
 
45
45
  It uses `curl --max-time 2 || true` so a Yoru outage never stalls your terminal — Claude Code doesn't notice the hook is even there.
46
46
 
47
+ ## Multiple devs, multiple identities
48
+
49
+ Each paired identity (server + token) gets its own local slot under `~/.config/yoru/identities/`. The shared tailer and every hook event attribute to whichever identity is **active** — there is no per-process attribution.
50
+
51
+ - **One dev per machine (the normal case):** no ambiguity — one OS user, one identity, all activity is unambiguously yours.
52
+ - **Same OS login, multiple identities paired** (e.g. you re-paired against a second org, or a shared machine): only one identity is active at a time. Concurrent work under that same login attributes to whichever identity is active *regardless of who actually ran it* — this is an explicit, documented limit, not a bug. Switch identities first with `yoru use <label>`.
53
+
54
+ Run `yoru doctor` any time to see the active identity and get warned if other paired identities exist unused on this machine.
55
+
47
56
  ## Self-host the server
48
57
 
49
58
  The CLI is MIT. The server (backend + dashboard) is AGPL-3.0 and lives at **[github.com/TsukumoHQ/yoru](https://github.com/TsukumoHQ/yoru)**. `docker-compose up`, then `yoru init --server https://your-host` — done. There is no hosted Yoru; you run it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yoru-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Yoru — audit-grade session receipts for autonomous AI coding agents."
5
5
  license = { text = "MIT" }
6
6
  license-files = ["LICENSE"]
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Topic :: Software Development :: Libraries :: Python Modules",
19
19
  ]
20
20
  requires-python = ">=3.10"
21
- dependencies = ["httpx>=0.27"]
21
+ dependencies = ["httpx>=0.27", "cryptography>=42"]
22
22
 
23
23
  [project.urls]
24
24
  Homepage = "https://yoru.sh"
@@ -30,7 +30,17 @@ Issues = "https://github.com/TsukumoHQ/cli-yoru/issues"
30
30
  yoru = "yoru_cli.cli:main"
31
31
 
32
32
  [project.optional-dependencies]
33
- dev = ["pytest>=7"]
33
+ # yoru-contract is dev-only for now: nothing in yoru-cli imports it yet (the
34
+ # CLI-side consumption ticket is a separate follow-up), and it isn't
35
+ # published to PyPI or vendored into the wheel — a hard runtime dependency
36
+ # here would break `pip install yoru-cli` with "No matching distribution
37
+ # found for yoru-contract". Once the CLI actually consumes it, the release
38
+ # mirror step needs to vendor its source into the wheel (or publish it
39
+ # separately) before this can become a real dependency.
40
+ dev = ["pytest>=7", "yoru-contract"]
41
+
42
+ [tool.uv.sources]
43
+ yoru-contract = { path = "../packages/yoru-contract" }
34
44
 
35
45
  [build-system]
36
46
  requires = ["hatchling"]
@@ -10,11 +10,18 @@ class ReceiptClient:
10
10
  self.base_url = base_url.rstrip("/")
11
11
  self.token = token
12
12
 
13
- def start_device_code(self, label: str | None = None) -> dict[str, Any]:
13
+ def start_device_code(
14
+ self, label: str | None = None, hostname: str | None = None
15
+ ) -> dict[str, Any]:
14
16
  """Begin the device-pairing handshake — no auth needed."""
17
+ body: dict[str, Any] = {}
18
+ if label:
19
+ body["label"] = label
20
+ if hostname:
21
+ body["hostname"] = hostname
15
22
  r = httpx.post(
16
23
  f"{self.base_url}/api/v1/auth/device-code",
17
- json={"label": label} if label else {},
24
+ json=body,
18
25
  timeout=5.0,
19
26
  )
20
27
  r.raise_for_status()
@@ -69,6 +76,29 @@ class ReceiptClient:
69
76
  r.raise_for_status()
70
77
  return r.json()
71
78
 
79
+ def logout(self) -> bool:
80
+ """Revoke this client's own token server-side (POST /auth/logout).
81
+
82
+ Self-revoke via bearer — no need to know the token's server-side row
83
+ id, which the CLI never learns (the poll response only ever returns
84
+ the raw token). Returns True if the token was revoked, False if it
85
+ was already invalid/revoked (a 401 here just means "nothing to
86
+ revoke", not a failure worth surfacing). Any other error status or
87
+ network failure raises, so the caller can warn loudly instead of
88
+ silently treating a failed revoke as done.
89
+ """
90
+ if not self.token:
91
+ raise RuntimeError("logout requires authentication")
92
+ r = httpx.post(
93
+ f"{self.base_url}/api/v1/auth/logout",
94
+ headers={"Authorization": f"Bearer {self.token}"},
95
+ timeout=5.0,
96
+ )
97
+ if r.status_code == 401:
98
+ return False
99
+ r.raise_for_status()
100
+ return True
101
+
72
102
  def get_share_consent(self) -> dict[str, Any]:
73
103
  """Returns {consented: bool, at: str|None} for the authenticated user."""
74
104
  if not self.token:
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from . import __version__
6
+ from . import (
7
+ config,
8
+ doctor_cmd,
9
+ enforce_cmd,
10
+ git_hook_run,
11
+ init_cmd,
12
+ logout_cmd,
13
+ rotate_cmd,
14
+ share_cmd,
15
+ tail_cmd,
16
+ update_cmd,
17
+ use_cmd,
18
+ verify_export_cmd,
19
+ )
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="yoru",
25
+ description="Yoru — audit-grade session receipts for autonomous AI coding agents.",
26
+ )
27
+ parser.add_argument(
28
+ "--version",
29
+ action="version",
30
+ version=f"yoru {__version__}",
31
+ )
32
+
33
+ subparsers = parser.add_subparsers(
34
+ dest="cmd",
35
+ required=True,
36
+ metavar="{init,use,logout,rotate,tail,doctor,share,update,verify-export,enforce}",
37
+ )
38
+
39
+ p_init = subparsers.add_parser(
40
+ "init",
41
+ help="Install the Claude Code hook and pair a local identity under ~/.config/yoru/.",
42
+ )
43
+ p_init.add_argument(
44
+ "--server",
45
+ required=True,
46
+ help="Backend URL of your Yoru server, e.g. https://yoru.acme.com. "
47
+ "Yoru is self-hosted — there is no default server.",
48
+ )
49
+ p_init.add_argument(
50
+ "--token",
51
+ default=None,
52
+ help="Pre-minted hook token (rcpt_...) — for headless/CI/server setups. "
53
+ "Also read from $YORU_TOKEN. Without this, yoru init launches "
54
+ "interactive device pairing.",
55
+ )
56
+ p_init.add_argument(
57
+ "--label",
58
+ default=None,
59
+ help="Human-readable machine label shown in the dashboard "
60
+ "(default: <hostname> · <os>).",
61
+ )
62
+ p_init.add_argument(
63
+ "--no-browser",
64
+ action="store_true",
65
+ help="Don't try to auto-open the pairing URL in a browser.",
66
+ )
67
+ p_init.add_argument("--force", action="store_true", help="Overwrite an existing install.")
68
+ p_init.add_argument(
69
+ "--backfill-git",
70
+ action="store_true",
71
+ help="OPT-IN: also walk this repo's full pre-existing git history "
72
+ "and spool it (in addition to the normal from-here-on capture). "
73
+ "Off by default — see docs/SELF-HOST.md.",
74
+ )
75
+
76
+ p_use = subparsers.add_parser(
77
+ "use",
78
+ help="Switch the active paired identity (no arg: list paired identities).",
79
+ )
80
+ p_use.add_argument(
81
+ "label",
82
+ nargs="?",
83
+ default=None,
84
+ help="identity_label (or identity_id) to switch to. Omit to list.",
85
+ )
86
+
87
+ subparsers.add_parser(
88
+ "logout",
89
+ help="Revoke the current token server-side and forget it locally.",
90
+ )
91
+
92
+ p_rotate = subparsers.add_parser(
93
+ "rotate",
94
+ help="Replace the current token with a new one, revoking the old "
95
+ "one server-side (same server as the existing pairing).",
96
+ )
97
+ p_rotate.add_argument(
98
+ "--token",
99
+ default=None,
100
+ help="Pre-minted replacement token — for headless/CI/server setups. "
101
+ "Also read from $YORU_TOKEN. Without this, yoru rotate launches "
102
+ "interactive device pairing.",
103
+ )
104
+ p_rotate.add_argument(
105
+ "--label",
106
+ default=None,
107
+ help="Human-readable machine label for the new pairing "
108
+ "(default: <hostname> · <os>).",
109
+ )
110
+ p_rotate.add_argument(
111
+ "--no-browser",
112
+ action="store_true",
113
+ help="Don't try to auto-open the pairing URL in a browser.",
114
+ )
115
+
116
+ p_tail = subparsers.add_parser(
117
+ "tail",
118
+ help="Read JSON events from stdin and POST them as a batch (dev/debug).",
119
+ )
120
+ p_tail.add_argument(
121
+ "--server",
122
+ default=None,
123
+ help="Backend URL (default: the server saved in config by `yoru init`).",
124
+ )
125
+ p_tail.add_argument("--session-id", default=None, help="Session id to stamp on events missing one.")
126
+
127
+ subparsers.add_parser(
128
+ "doctor",
129
+ help="Diagnose the install: config, backend, token, hook.",
130
+ )
131
+
132
+ p_share = subparsers.add_parser(
133
+ "share",
134
+ help="Flip a session public and copy its yoru.sh/s/<id> URL to the clipboard.",
135
+ )
136
+ p_share.add_argument(
137
+ "session_id",
138
+ help="Session id to share (or revoke). Find it in the dashboard URL or the session header.",
139
+ )
140
+ p_share.add_argument(
141
+ "--revoke",
142
+ action="store_true",
143
+ help="Flip the session back to private instead of sharing it.",
144
+ )
145
+
146
+ p_update = subparsers.add_parser(
147
+ "update",
148
+ help="Self-update the CLI to the latest release (pip), and refresh the hook.",
149
+ )
150
+ p_update.add_argument(
151
+ "--force",
152
+ action="store_true",
153
+ help="Bypass the dev-build and no-downgrade guards (explicit reinstall/downgrade).",
154
+ )
155
+ p_update.add_argument(
156
+ "--check",
157
+ action="store_true",
158
+ help="Report whether an update is available and exit — install nothing.",
159
+ )
160
+ p_update.add_argument(
161
+ "--server",
162
+ nargs="?",
163
+ const="",
164
+ default=None,
165
+ metavar="URL",
166
+ help="Check the running SERVER's version (notify-only) instead of self-updating "
167
+ "the CLI. Bare --server uses your configured server; --server URL targets that "
168
+ "instance. Never pulls or restarts the server.",
169
+ )
170
+
171
+ p_verify = subparsers.add_parser(
172
+ "verify-export",
173
+ help="Offline-verify a signed audit-export bundle (Ed25519/DSSE + chain).",
174
+ )
175
+ p_verify.add_argument(
176
+ "bundle",
177
+ help="Path to the signed bundle JSON (or '-' to read from stdin).",
178
+ )
179
+ p_verify.add_argument(
180
+ "--pubkey-fingerprint",
181
+ default=None,
182
+ metavar="SHA256_HEX",
183
+ help="Pin the deployer's public-key fingerprint (published out-of-band). "
184
+ "Without it the check is tamper-evident only — the embedded key is "
185
+ "trusted on faith.",
186
+ )
187
+
188
+ p_enforce = subparsers.add_parser(
189
+ "enforce",
190
+ help="Manage the OPT-IN enforcement gate that halts dangerous ops "
191
+ "(default OFF; the passive audit trail is unaffected).",
192
+ )
193
+ _eg = p_enforce.add_mutually_exclusive_group()
194
+ _eg.add_argument("--enable", action="store_true",
195
+ help="Install + enable the PreToolUse enforcement gate.")
196
+ _eg.add_argument("--disable", action="store_true",
197
+ help="Disable the gate (no-arg `enforce` shows status).")
198
+
199
+ # Internal: the enforcement hook invokes this with the PreToolUse event on
200
+ # stdin; it emits a deny decision only on a match, else nothing.
201
+ subparsers.add_parser(
202
+ "enforce-check",
203
+ help="(internal) decision endpoint the enforcement hook calls.",
204
+ )
205
+
206
+ # Internal: the installed post-commit/pre-push git hooks (git_hooks.py)
207
+ # shell out to this — a real console-script entrypoint, not
208
+ # `python3 -m yoru_cli...`, so it resolves to the correct interpreter
209
+ # under pipx/uv-tool-install regardless of what `python3` means on PATH
210
+ # (B3 slice1 round-2 review finding).
211
+ p_ghr = subparsers.add_parser(
212
+ "git-hook-run",
213
+ help="(internal) invoked by the installed git hooks — captures commit/push events.",
214
+ )
215
+ p_ghr.add_argument("hook_kind", choices=["post-commit", "pre-push"])
216
+ p_ghr.add_argument("hook_args", nargs="*")
217
+
218
+ return parser
219
+
220
+
221
+ def main(argv: list[str] | None = None) -> int:
222
+ parser = _build_parser()
223
+ args = parser.parse_args(argv)
224
+
225
+ if args.cmd == "tail" and args.server is None:
226
+ cfg = config.load() or {}
227
+ args.server = cfg.get("server")
228
+ if not args.server:
229
+ parser.error(
230
+ "no server configured — run `yoru init --server <url>` first, "
231
+ "or pass --server explicitly."
232
+ )
233
+
234
+ if args.cmd == "init":
235
+ return init_cmd.run(args)
236
+ if args.cmd == "use":
237
+ return use_cmd.run(args)
238
+ if args.cmd == "logout":
239
+ return logout_cmd.run(args)
240
+ if args.cmd == "rotate":
241
+ return rotate_cmd.run(args)
242
+ if args.cmd == "tail":
243
+ return tail_cmd.run(args)
244
+ if args.cmd == "doctor":
245
+ return doctor_cmd.run(args)
246
+ if args.cmd == "share":
247
+ return share_cmd.run(args)
248
+ if args.cmd == "update":
249
+ return update_cmd.run(args)
250
+ if args.cmd == "verify-export":
251
+ return verify_export_cmd.run(args)
252
+ if args.cmd == "enforce":
253
+ return enforce_cmd.run(args)
254
+ if args.cmd == "enforce-check":
255
+ return enforce_cmd.check()
256
+ if args.cmd == "git-hook-run":
257
+ return git_hook_run.main([args.hook_kind, *args.hook_args])
258
+
259
+ parser.error(f"unknown command: {args.cmd!r}")
260
+ return 2