yoru-cli 0.1.2__tar.gz → 0.2.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 (31) hide show
  1. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/.gitignore +10 -0
  2. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/PKG-INFO +14 -11
  3. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/README.md +11 -8
  4. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/pyproject.toml +3 -3
  5. yoru_cli-0.2.0/src/yoru_cli/api.py +94 -0
  6. yoru_cli-0.2.0/src/yoru_cli/cli.py +137 -0
  7. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/doctor_cmd.py +10 -9
  8. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/hook_template.py +14 -15
  9. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/init_cmd.py +33 -9
  10. yoru_cli-0.2.0/src/yoru_cli/share_cmd.py +188 -0
  11. yoru_cli-0.2.0/src/yoru_cli/skill_template.py +149 -0
  12. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/transcript_tailer.py +81 -34
  13. yoru_cli-0.2.0/src/yoru_cli/update_cmd.py +250 -0
  14. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/tests/test_cli.py +4 -1
  15. yoru_cli-0.2.0/tests/test_doctor.py +34 -0
  16. yoru_cli-0.2.0/tests/test_init.py +161 -0
  17. yoru_cli-0.2.0/tests/test_update.py +187 -0
  18. yoru_cli-0.1.2/src/yoru_cli/api.py +0 -40
  19. yoru_cli-0.1.2/src/yoru_cli/cli.py +0 -84
  20. yoru_cli-0.1.2/tests/test_doctor.py +0 -18
  21. yoru_cli-0.1.2/tests/test_init.py +0 -164
  22. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/LICENSE +0 -0
  23. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/__init__.py +0 -0
  24. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/__main__.py +0 -0
  25. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/config.py +0 -0
  26. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/src/yoru_cli/tail_cmd.py +0 -0
  27. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/tests/__init__.py +0 -0
  28. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/tests/test_hook_template.py +0 -0
  29. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/tests/test_init_settings_merge.py +0 -0
  30. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/tests/test_tail.py +0 -0
  31. {yoru_cli-0.1.2 → yoru_cli-0.2.0}/uv.lock +0 -0
@@ -70,3 +70,13 @@ backend/receipt.db
70
70
  # Runtime caches populated by pricing warmup etc.
71
71
  backend/data/*.json
72
72
  .saasforge
73
+
74
+ # Local-auth JWT signing secret (auto-generated, machine-local — NEVER commit)
75
+ backend/data/.auth_jwt_secret
76
+
77
+ # Embedding cache (trovex / fastembed)
78
+ .fastembed_cache/
79
+ .vercel
80
+
81
+ # install-smoke pre-release wheel (transient)
82
+ install-smoke/_wheel/
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yoru-cli
3
- Version: 0.1.2
3
+ Version: 0.2.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
7
- Project-URL: Repository, https://github.com/yoru-sh/cli
8
- Project-URL: Issues, https://github.com/yoru-sh/cli/issues
7
+ Project-URL: Repository, https://github.com/TsukumoHQ/cli-yoru
8
+ Project-URL: Issues, https://github.com/TsukumoHQ/cli-yoru/issues
9
9
  Author-email: Yoru authors <hello@yoru.sh>
10
10
  License: MIT
11
11
  License-File: LICENSE
@@ -34,20 +34,23 @@ The Claude Code hook installer for Yoru. One command installs a `PostToolUse` ho
34
34
  ## Install
35
35
 
36
36
  ```bash
37
- pip install yoru-cli
38
- yoru init
37
+ pipx install yoru-cli # or: uv tool install yoru-cli
38
+ yoru init --server https://yoru.acme.com
39
39
  ```
40
40
 
41
- `yoru init` opens a pairing URL in your browser, writes `~/.claude/hooks/yoru.sh`, and registers the hook in `~/.claude/settings.json`. By default it points at Yoru Cloud ([yoru.sh](https://yoru.sh)) — free forever for one developer.
41
+ Use `pipx`/`uv` a bare `pip install yoru-cli` fails on Homebrew/Debian Python with
42
+ `error: externally-managed-environment` (PEP 668). No pipx? A venv works too:
43
+ `python3 -m venv .venv && .venv/bin/pip install yoru-cli`.
44
+
45
+ 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
46
 
43
47
  Requires Python 3.10+. One runtime dep: `httpx`.
44
48
 
45
49
  ## Usage
46
50
 
47
51
  ```bash
48
- yoru init # pair against https://api.yoru.sh
49
- yoru init --server https://yoru.acme # pair against your self-hosted server
50
- yoru init --token yoru_xxx --force # non-interactive (CI / scripted rotation)
52
+ yoru init --server https://yoru.acme.com # pair against your server (--server required)
53
+ yoru init --server https://yoru.acme.com --token yoru_xxx --force # non-interactive (CI / scripted rotation)
51
54
 
52
55
  yoru tail < events.jsonl # post a batch of events (debug)
53
56
 
@@ -69,8 +72,8 @@ It uses `curl --max-time 2 || true` so a Yoru outage never stalls your terminal
69
72
 
70
73
  ## Self-host the server
71
74
 
72
- The CLI is MIT. The server (backend + dashboard) is AGPL-3.0 and lives at **[github.com/yoru-sh/yoru](https://github.com/yoru-sh/yoru)**. `docker-compose up`, then `yoru init --server https://your-host` — done.
75
+ 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.
73
76
 
74
77
  ## License
75
78
 
76
- [MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/yoru-sh/cli](https://github.com/yoru-sh/cli).
79
+ [MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/TsukumoHQ/cli-yoru](https://github.com/TsukumoHQ/cli-yoru).
@@ -8,20 +8,23 @@ 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
12
- yoru init
11
+ pipx install yoru-cli # or: uv tool install yoru-cli
12
+ yoru init --server https://yoru.acme.com
13
13
  ```
14
14
 
15
- `yoru init` opens a pairing URL in your browser, writes `~/.claude/hooks/yoru.sh`, and registers the hook in `~/.claude/settings.json`. By default it points at Yoru Cloud ([yoru.sh](https://yoru.sh)) — free forever for one developer.
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
+
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`.
18
22
 
19
23
  ## Usage
20
24
 
21
25
  ```bash
22
- yoru init # pair against https://api.yoru.sh
23
- yoru init --server https://yoru.acme # pair against your self-hosted server
24
- yoru init --token yoru_xxx --force # non-interactive (CI / scripted rotation)
26
+ yoru init --server https://yoru.acme.com # pair against your server (--server required)
27
+ yoru init --server https://yoru.acme.com --token yoru_xxx --force # non-interactive (CI / scripted rotation)
25
28
 
26
29
  yoru tail < events.jsonl # post a batch of events (debug)
27
30
 
@@ -43,8 +46,8 @@ It uses `curl --max-time 2 || true` so a Yoru outage never stalls your terminal
43
46
 
44
47
  ## Self-host the server
45
48
 
46
- The CLI is MIT. The server (backend + dashboard) is AGPL-3.0 and lives at **[github.com/yoru-sh/yoru](https://github.com/yoru-sh/yoru)**. `docker-compose up`, then `yoru init --server https://your-host` — done.
49
+ 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.
47
50
 
48
51
  ## License
49
52
 
50
- [MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/yoru-sh/cli](https://github.com/yoru-sh/cli).
53
+ [MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/TsukumoHQ/cli-yoru](https://github.com/TsukumoHQ/cli-yoru).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yoru-cli"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Yoru — audit-grade session receipts for autonomous AI coding agents."
5
5
  license = { text = "MIT" }
6
6
  license-files = ["LICENSE"]
@@ -23,8 +23,8 @@ dependencies = ["httpx>=0.27"]
23
23
  [project.urls]
24
24
  Homepage = "https://yoru.sh"
25
25
  Documentation = "https://yoru.sh/docs"
26
- Repository = "https://github.com/yoru-sh/cli"
27
- Issues = "https://github.com/yoru-sh/cli/issues"
26
+ Repository = "https://github.com/TsukumoHQ/cli-yoru"
27
+ Issues = "https://github.com/TsukumoHQ/cli-yoru/issues"
28
28
 
29
29
  [project.scripts]
30
30
  yoru = "yoru_cli.cli:main"
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+
8
+ class ReceiptClient:
9
+ def __init__(self, base_url: str, token: str | None = None) -> None:
10
+ self.base_url = base_url.rstrip("/")
11
+ self.token = token
12
+
13
+ def start_device_code(self, label: str | None = None) -> dict[str, Any]:
14
+ """Begin the device-pairing handshake — no auth needed."""
15
+ r = httpx.post(
16
+ f"{self.base_url}/api/v1/auth/device-code",
17
+ json={"label": label} if label else {},
18
+ timeout=5.0,
19
+ )
20
+ r.raise_for_status()
21
+ return r.json()
22
+
23
+ def poll_device_code(self, device_code: str) -> dict[str, Any]:
24
+ """Poll for approval — returns {status, token?}."""
25
+ r = httpx.post(
26
+ f"{self.base_url}/api/v1/auth/device-code/poll",
27
+ json={"device_code": device_code},
28
+ timeout=10.0,
29
+ )
30
+ r.raise_for_status()
31
+ return r.json()
32
+
33
+ def post_events(self, events: list[dict[str, Any]]) -> httpx.Response:
34
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
35
+ return httpx.post(
36
+ f"{self.base_url}/api/v1/sessions/events",
37
+ json={"events": events},
38
+ headers=headers,
39
+ timeout=5.0,
40
+ )
41
+
42
+ def share_session(self, session_id: str) -> dict[str, Any]:
43
+ """Flip a session public (#79). Requires bearer token — 401 otherwise.
44
+
45
+ Backend is idempotent: re-POST on an already-public session returns
46
+ the same `public_url`. 404 on cross-user (token's user doesn't own
47
+ this session) — callers should treat that as "not your session".
48
+ """
49
+ if not self.token:
50
+ raise RuntimeError("share_session requires authentication (run `yoru init`)")
51
+ r = httpx.post(
52
+ f"{self.base_url}/api/v1/sessions/{session_id}/share",
53
+ json={"source": "cli"},
54
+ headers={"Authorization": f"Bearer {self.token}"},
55
+ timeout=10.0,
56
+ )
57
+ r.raise_for_status()
58
+ return r.json()
59
+
60
+ def revoke_share(self, session_id: str) -> dict[str, Any]:
61
+ """Flip a session back to private (#79). Idempotent."""
62
+ if not self.token:
63
+ raise RuntimeError("revoke_share requires authentication (run `yoru init`)")
64
+ r = httpx.post(
65
+ f"{self.base_url}/api/v1/sessions/{session_id}/share/revoke",
66
+ headers={"Authorization": f"Bearer {self.token}"},
67
+ timeout=10.0,
68
+ )
69
+ r.raise_for_status()
70
+ return r.json()
71
+
72
+ def get_share_consent(self) -> dict[str, Any]:
73
+ """Returns {consented: bool, at: str|None} for the authenticated user."""
74
+ if not self.token:
75
+ raise RuntimeError("get_share_consent requires authentication")
76
+ r = httpx.get(
77
+ f"{self.base_url}/api/v1/account/share-consent",
78
+ headers={"Authorization": f"Bearer {self.token}"},
79
+ timeout=5.0,
80
+ )
81
+ r.raise_for_status()
82
+ return r.json()
83
+
84
+ def post_share_consent(self) -> dict[str, Any]:
85
+ """Stamp share consent for the authenticated user (idempotent)."""
86
+ if not self.token:
87
+ raise RuntimeError("post_share_consent requires authentication")
88
+ r = httpx.post(
89
+ f"{self.base_url}/api/v1/account/share-consent",
90
+ headers={"Authorization": f"Bearer {self.token}"},
91
+ timeout=5.0,
92
+ )
93
+ r.raise_for_status()
94
+ return r.json()
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from . import __version__
6
+ from . import config, doctor_cmd, init_cmd, share_cmd, tail_cmd, update_cmd
7
+
8
+
9
+ def _build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(
11
+ prog="yoru",
12
+ description="Yoru — audit-grade session receipts for autonomous AI coding agents.",
13
+ )
14
+ parser.add_argument(
15
+ "--version",
16
+ action="version",
17
+ version=f"yoru {__version__}",
18
+ )
19
+
20
+ subparsers = parser.add_subparsers(
21
+ dest="cmd", required=True, metavar="{init,tail,doctor,share,update}"
22
+ )
23
+
24
+ p_init = subparsers.add_parser(
25
+ "init",
26
+ help="Install the Claude Code hook and write ~/.config/yoru/config.json.",
27
+ )
28
+ p_init.add_argument(
29
+ "--server",
30
+ required=True,
31
+ help="Backend URL of your Yoru server, e.g. https://yoru.acme.com. "
32
+ "Yoru is self-hosted — there is no default server.",
33
+ )
34
+ p_init.add_argument(
35
+ "--token",
36
+ default=None,
37
+ help="Pre-minted hook token (rcpt_...) — for headless/CI/server setups. "
38
+ "Also read from $YORU_TOKEN. Without this, yoru init launches "
39
+ "interactive device pairing.",
40
+ )
41
+ p_init.add_argument(
42
+ "--label",
43
+ default=None,
44
+ help="Human-readable machine label shown in the dashboard "
45
+ "(default: <hostname> · <os>).",
46
+ )
47
+ p_init.add_argument(
48
+ "--no-browser",
49
+ action="store_true",
50
+ help="Don't try to auto-open the pairing URL in a browser.",
51
+ )
52
+ p_init.add_argument("--force", action="store_true", help="Overwrite an existing install.")
53
+
54
+ p_tail = subparsers.add_parser(
55
+ "tail",
56
+ help="Read JSON events from stdin and POST them as a batch (dev/debug).",
57
+ )
58
+ p_tail.add_argument(
59
+ "--server",
60
+ default=None,
61
+ help="Backend URL (default: the server saved in config by `yoru init`).",
62
+ )
63
+ p_tail.add_argument("--session-id", default=None, help="Session id to stamp on events missing one.")
64
+
65
+ subparsers.add_parser(
66
+ "doctor",
67
+ help="Diagnose the install: config, backend, token, hook.",
68
+ )
69
+
70
+ p_share = subparsers.add_parser(
71
+ "share",
72
+ help="Flip a session public and copy its yoru.sh/s/<id> URL to the clipboard.",
73
+ )
74
+ p_share.add_argument(
75
+ "session_id",
76
+ help="Session id to share (or revoke). Find it in the dashboard URL or the session header.",
77
+ )
78
+ p_share.add_argument(
79
+ "--revoke",
80
+ action="store_true",
81
+ help="Flip the session back to private instead of sharing it.",
82
+ )
83
+
84
+ p_update = subparsers.add_parser(
85
+ "update",
86
+ help="Self-update the CLI to the latest release (pip), and refresh the hook.",
87
+ )
88
+ p_update.add_argument(
89
+ "--force",
90
+ action="store_true",
91
+ help="Bypass the dev-build and no-downgrade guards (explicit reinstall/downgrade).",
92
+ )
93
+ p_update.add_argument(
94
+ "--check",
95
+ action="store_true",
96
+ help="Report whether an update is available and exit — install nothing.",
97
+ )
98
+ p_update.add_argument(
99
+ "--server",
100
+ nargs="?",
101
+ const="",
102
+ default=None,
103
+ metavar="URL",
104
+ help="Check the running SERVER's version (notify-only) instead of self-updating "
105
+ "the CLI. Bare --server uses your configured server; --server URL targets that "
106
+ "instance. Never pulls or restarts the server.",
107
+ )
108
+
109
+ return parser
110
+
111
+
112
+ def main(argv: list[str] | None = None) -> int:
113
+ parser = _build_parser()
114
+ args = parser.parse_args(argv)
115
+
116
+ if args.cmd == "tail" and args.server is None:
117
+ cfg = config.load() or {}
118
+ args.server = cfg.get("server")
119
+ if not args.server:
120
+ parser.error(
121
+ "no server configured — run `yoru init --server <url>` first, "
122
+ "or pass --server explicitly."
123
+ )
124
+
125
+ if args.cmd == "init":
126
+ return init_cmd.run(args)
127
+ if args.cmd == "tail":
128
+ return tail_cmd.run(args)
129
+ if args.cmd == "doctor":
130
+ return doctor_cmd.run(args)
131
+ if args.cmd == "share":
132
+ return share_cmd.run(args)
133
+ if args.cmd == "update":
134
+ return update_cmd.run(args)
135
+
136
+ parser.error(f"unknown command: {args.cmd!r}")
137
+ return 2
@@ -6,13 +6,14 @@ Read-only check of the install:
6
6
  3. hook-token valid (GET /hook-tokens) → else exit 3 on 401
7
7
  4. ~/.claude/hooks/yoru.sh is 0755 → else exit 4
8
8
 
9
- Prints ✓ lines to stdout on success (exit 0). Failures go to stderr with a
10
- short human-readable reason. No fixes attempted.
9
+ Prints a line to stdout as each stage passes, so a failure leaves a visible
10
+ trail of what already worked (✓ config / ✓ backend / token revoked → the break
11
+ is obvious). Failures go to stderr with a short human-readable reason, then a
12
+ non-zero exit. No fixes attempted.
11
13
  """
12
14
  from __future__ import annotations
13
15
 
14
16
  import argparse
15
- import os
16
17
  import stat
17
18
  import sys
18
19
  from pathlib import Path
@@ -42,6 +43,9 @@ def run(args: argparse.Namespace) -> int: # noqa: ARG001 — argparse hands arg
42
43
  if not server or not token:
43
44
  print("yoru init not run", file=sys.stderr)
44
45
  return 1
46
+ # Each stage prints its check the moment it passes, so a later failure
47
+ # leaves a visible trail of what DID work.
48
+ print(f"✓ config at ~/.config/yoru/config.json (token {_token_suffix(token)})")
45
49
 
46
50
  # 2. backend /health/ready
47
51
  try:
@@ -52,6 +56,7 @@ def run(args: argparse.Namespace) -> int: # noqa: ARG001 — argparse hands arg
52
56
  if r.status_code != 200:
53
57
  print(f"backend unreachable at {server}", file=sys.stderr)
54
58
  return 2
59
+ print(f"✓ backend {server} reachable")
55
60
 
56
61
  # 3. hook-token validity
57
62
  try:
@@ -72,6 +77,8 @@ def run(args: argparse.Namespace) -> int: # noqa: ARG001 — argparse hands arg
72
77
  file=sys.stderr,
73
78
  )
74
79
  return 3
80
+ user = cfg.get("user") or "authenticated"
81
+ print(f"✓ hook-token valid (user: {user})")
75
82
 
76
83
  # 4. hook file + perms
77
84
  hook = _hook_path()
@@ -82,11 +89,5 @@ def run(args: argparse.Namespace) -> int: # noqa: ARG001 — argparse hands arg
82
89
  if mode != 0o755:
83
90
  print("hook file missing or not 0755", file=sys.stderr)
84
91
  return 4
85
-
86
- # all green
87
- user = cfg.get("user") or "authenticated"
88
- print(f"\u2713 config at ~/.config/yoru/config.json (token {_token_suffix(token)})")
89
- print(f"\u2713 backend {server} reachable")
90
- print(f"\u2713 hook-token valid (user: {user})")
91
92
  print("\u2713 hook installed at ~/.claude/hooks/yoru.sh")
92
93
  return 0
@@ -81,24 +81,23 @@ if git_branch: e["git_branch"] = git_branch
81
81
  hen=e.get("hook_event_name")
82
82
  if hen=="SessionStart":
83
83
  e["kind"]="session_start"
84
- elif hen=="UserPromptSubmit":
85
- e["kind"]="message"
86
- e["tool"]="user"
87
- p=e.get("prompt")
88
- if isinstance(p,str) and p: e["content"]=p[:2000]
89
- elif hen=="Notification":
90
- e["kind"]="message"
91
- e["tool"]="notification"
92
- m=e.get("message")
93
- if isinstance(m,str) and m: e["content"]=m[:2000]
94
- elif hen=="SubagentStop":
95
- e["kind"]="message"
96
- e["tool"]="subagent"
97
- e["content"]="subagent stopped"
98
84
  elif hen=="Stop":
99
85
  e["kind"]="session_end"
100
- # PostToolUse / PreToolUse: leave kind unset backend _infer_kind() from tool
86
+ # Discreet share CTA after session_end (surfaced via hook stderr).
87
+ # (avoid apostrophes here - this block is inside a bash $(python3 -c '...'))
88
+ sid_stop = original.get("session_id") or original.get("sessionId") or ""
89
+ if isinstance(sid_stop, str) and sid_stop:
90
+ try:
91
+ sys.stderr.write(f"\\n[yoru] session ended - make it public: yoru share {sid_stop}\\n")
92
+ except Exception:
93
+ pass
94
+ else:
95
+ # Tool calls, prompts, notifications now come from the durable transcript
96
+ # tailer (yoru_cli.transcript_tailer): single source, no duplicates, and
97
+ # they survive backend downtime. The hook only ships session lifecycle.
98
+ sys.exit(0)
101
99
  print(json.dumps({"events":[e]}))')
100
+ [ -z "${BODY}" ] && exit 0
102
101
  curl -sS --max-time 2 -X POST "${SERVER}/api/v1/sessions/events" \\
103
102
  -H "Authorization: Bearer ${TOKEN}" \\
104
103
  -H "Content-Type: application/json" \\
@@ -16,6 +16,7 @@ import httpx
16
16
  from . import config
17
17
  from .api import ReceiptClient
18
18
  from .hook_template import HOOK_SCRIPT
19
+ from .skill_template import SKILL_MD, SKILL_NAME
19
20
 
20
21
 
21
22
  def _default_label() -> str:
@@ -106,7 +107,7 @@ def _pair_device(server: str, label: str, *, no_browser: bool) -> str | None:
106
107
  interval = int(start.get("interval", 2))
107
108
 
108
109
  print()
109
- print(f" Pair this device with your Yoru account:")
110
+ print(" Pair this device with your Yoru account:")
110
111
  print(f" 1. Open {verify_uri}")
111
112
  print(f" 2. Enter {user_code}")
112
113
  print()
@@ -143,6 +144,35 @@ def _pair_device(server: str, label: str, *, no_browser: bool) -> str | None:
143
144
  return None
144
145
 
145
146
 
147
+ def _install_skill() -> Path:
148
+ """(Re)write the PUBLIC end-user yoru Claude Code skill so a fresh Claude
149
+ session can drive setup + usage. Lands at ~/.claude/skills/yoru/SKILL.md,
150
+ where Claude Code resolves user-level skills. Idempotent."""
151
+ skill_dir = Path.home() / ".claude" / "skills" / SKILL_NAME
152
+ os.makedirs(skill_dir, exist_ok=True)
153
+ skill_path = skill_dir / "SKILL.md"
154
+ skill_path.write_text(SKILL_MD, encoding="utf-8")
155
+ return skill_path
156
+
157
+
158
+ def refresh_hook_assets() -> tuple[Path, Path, Path]:
159
+ """(Re)write the Claude Code hook script + the public yoru skill, and
160
+ (re)register the hook in settings.json. Idempotent — used by `init` (first
161
+ install) and `update` (refresh to the new version + repair the wiring).
162
+ Returns (hook_path, settings_path, skill_path). Does NOT touch config."""
163
+ hook_dir = Path.home() / ".claude" / "hooks"
164
+ hook_path = hook_dir / "yoru.sh"
165
+ os.makedirs(hook_dir, exist_ok=True)
166
+ hook_path.write_text(HOOK_SCRIPT, encoding="utf-8")
167
+ os.chmod(hook_path, 0o755)
168
+
169
+ settings_path = Path.home() / ".claude" / "settings.json"
170
+ _merge_settings_json(settings_path, hook_path)
171
+
172
+ skill_path = _install_skill()
173
+ return hook_path, settings_path, skill_path
174
+
175
+
146
176
  def run(args: argparse.Namespace) -> int:
147
177
  if config.exists() and not getattr(args, "force", False):
148
178
  print("Already installed (use --force to overwrite)", file=sys.stderr)
@@ -167,17 +197,11 @@ def run(args: argparse.Namespace) -> int:
167
197
  "created_at": datetime.now(timezone.utc).isoformat(),
168
198
  })
169
199
 
170
- hook_dir = Path.home() / ".claude" / "hooks"
171
- hook_path = hook_dir / "yoru.sh"
172
- os.makedirs(hook_dir, exist_ok=True)
173
- hook_path.write_text(HOOK_SCRIPT, encoding="utf-8")
174
- os.chmod(hook_path, 0o755)
175
-
176
- settings_path = Path.home() / ".claude" / "settings.json"
177
- _merge_settings_json(settings_path, hook_path)
200
+ refresh_hook_assets()
178
201
 
179
202
  print("\u2713 config \u2192 ~/.config/yoru/config.json")
180
203
  print("\u2713 hook \u2192 ~/.claude/hooks/yoru.sh")
181
204
  print("\u2713 settings \u2192 ~/.claude/settings.json (hook registered)")
205
+ print("\u2713 skill \u2192 ~/.claude/skills/yoru/SKILL.md (Claude can drive setup + usage)")
182
206
  print("Next: run Claude Code normally; first event streams to /sessions/events.")
183
207
  return 0