yoru-cli 0.1.3__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 (59) hide show
  1. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/.gitignore +6 -0
  2. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/PKG-INFO +18 -3
  3. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/README.md +14 -1
  4. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/pyproject.toml +13 -3
  5. {yoru_cli-0.1.3 → 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.1.3 → yoru_cli-0.3.0}/src/yoru_cli/hook_template.py +14 -3
  17. yoru_cli-0.3.0/src/yoru_cli/init_cmd.py +315 -0
  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.3.0/src/yoru_cli/skill_template.py +149 -0
  21. yoru_cli-0.3.0/src/yoru_cli/transcript_tailer.py +820 -0
  22. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/src/yoru_cli/update_cmd.py +1 -1
  23. yoru_cli-0.3.0/src/yoru_cli/use_cmd.py +44 -0
  24. yoru_cli-0.3.0/src/yoru_cli/verify_export_cmd.py +95 -0
  25. yoru_cli-0.3.0/tests/test_cli.py +89 -0
  26. yoru_cli-0.3.0/tests/test_config.py +155 -0
  27. yoru_cli-0.3.0/tests/test_doctor.py +181 -0
  28. yoru_cli-0.3.0/tests/test_enforce.py +247 -0
  29. yoru_cli-0.3.0/tests/test_enforce_hook_script.py +93 -0
  30. yoru_cli-0.3.0/tests/test_git_hook_run.py +207 -0
  31. yoru_cli-0.3.0/tests/test_git_hooks.py +81 -0
  32. yoru_cli-0.3.0/tests/test_git_reconcile.py +400 -0
  33. yoru_cli-0.3.0/tests/test_hook_template.py +122 -0
  34. yoru_cli-0.3.0/tests/test_init.py +440 -0
  35. yoru_cli-0.3.0/tests/test_logout.py +86 -0
  36. yoru_cli-0.3.0/tests/test_rotate.py +126 -0
  37. yoru_cli-0.3.0/tests/test_transcript_tailer.py +565 -0
  38. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/tests/test_update.py +1 -1
  39. yoru_cli-0.3.0/tests/test_use.py +84 -0
  40. yoru_cli-0.3.0/tests/test_verify_export.py +160 -0
  41. yoru_cli-0.3.0/uv.lock +577 -0
  42. yoru_cli-0.1.3/src/yoru_cli/cli.py +0 -137
  43. yoru_cli-0.1.3/src/yoru_cli/config.py +0 -43
  44. yoru_cli-0.1.3/src/yoru_cli/doctor_cmd.py +0 -92
  45. yoru_cli-0.1.3/src/yoru_cli/init_cmd.py +0 -192
  46. yoru_cli-0.1.3/src/yoru_cli/transcript_tailer.py +0 -485
  47. yoru_cli-0.1.3/tests/test_cli.py +0 -38
  48. yoru_cli-0.1.3/tests/test_doctor.py +0 -18
  49. yoru_cli-0.1.3/tests/test_hook_template.py +0 -25
  50. yoru_cli-0.1.3/tests/test_init.py +0 -137
  51. yoru_cli-0.1.3/uv.lock +0 -230
  52. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/LICENSE +0 -0
  53. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/src/yoru_cli/__init__.py +0 -0
  54. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/src/yoru_cli/__main__.py +0 -0
  55. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/src/yoru_cli/share_cmd.py +0 -0
  56. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/src/yoru_cli/tail_cmd.py +0 -0
  57. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/tests/__init__.py +0 -0
  58. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/tests/test_init_settings_merge.py +0 -0
  59. {yoru_cli-0.1.3 → yoru_cli-0.3.0}/tests/test_tail.py +0 -0
@@ -77,3 +77,9 @@ backend/data/.auth_jwt_secret
77
77
  # Embedding cache (trovex / fastembed)
78
78
  .fastembed_cache/
79
79
  .vercel
80
+
81
+ # install-smoke pre-release wheel (transient)
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.1.3
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
@@ -34,10 +36,14 @@ The Claude Code hook installer for Yoru. One command installs a `PostToolUse` ho
34
36
  ## Install
35
37
 
36
38
  ```bash
37
- pip install yoru-cli
39
+ pipx install yoru-cli # or: uv tool install yoru-cli
38
40
  yoru init --server https://yoru.acme.com
39
41
  ```
40
42
 
43
+ Use `pipx`/`uv` — a bare `pip install yoru-cli` fails on Homebrew/Debian Python with
44
+ `error: externally-managed-environment` (PEP 668). No pipx? A venv works too:
45
+ `python3 -m venv .venv && .venv/bin/pip install yoru-cli`.
46
+
41
47
  Yoru is **self-hosted** — point the CLI at your own backend with `--server`. `yoru init` opens a pairing URL in your browser, writes `~/.claude/hooks/yoru.sh`, and registers the hook in `~/.claude/settings.json`. Don't have a server yet? [Stand one up](https://github.com/TsukumoHQ/yoru) — `docker-compose up`.
42
48
 
43
49
  Requires Python 3.10+. One runtime dep: `httpx`.
@@ -66,6 +72,15 @@ The installed script is ~20 lines of Bash that `POST`s one JSON event per Claude
66
72
 
67
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.
68
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
+
69
84
  ## Self-host the server
70
85
 
71
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.
@@ -8,10 +8,14 @@ The Claude Code hook installer for Yoru. One command installs a `PostToolUse` ho
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- pip install yoru-cli
11
+ pipx install yoru-cli # or: uv tool install yoru-cli
12
12
  yoru init --server https://yoru.acme.com
13
13
  ```
14
14
 
15
+ Use `pipx`/`uv` — a bare `pip install yoru-cli` fails on Homebrew/Debian Python with
16
+ `error: externally-managed-environment` (PEP 668). No pipx? A venv works too:
17
+ `python3 -m venv .venv && .venv/bin/pip install yoru-cli`.
18
+
15
19
  Yoru is **self-hosted** — point the CLI at your own backend with `--server`. `yoru init` opens a pairing URL in your browser, writes `~/.claude/hooks/yoru.sh`, and registers the hook in `~/.claude/settings.json`. Don't have a server yet? [Stand one up](https://github.com/TsukumoHQ/yoru) — `docker-compose up`.
16
20
 
17
21
  Requires Python 3.10+. One runtime dep: `httpx`.
@@ -40,6 +44,15 @@ The installed script is ~20 lines of Bash that `POST`s one JSON event per Claude
40
44
 
41
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.
42
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
+
43
56
  ## Self-host the server
44
57
 
45
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.1.3"
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