yoru-cli 0.1.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.
- yoru_cli/__init__.py +1 -0
- yoru_cli/__main__.py +3 -0
- yoru_cli/api.py +40 -0
- yoru_cli/cli.py +84 -0
- yoru_cli/config.py +43 -0
- yoru_cli/doctor_cmd.py +92 -0
- yoru_cli/hook_template.py +119 -0
- yoru_cli/init_cmd.py +183 -0
- yoru_cli/tail_cmd.py +51 -0
- yoru_cli/transcript_tailer.py +438 -0
- yoru_cli-0.1.0.dist-info/METADATA +61 -0
- yoru_cli-0.1.0.dist-info/RECORD +15 -0
- yoru_cli-0.1.0.dist-info/WHEEL +4 -0
- yoru_cli-0.1.0.dist-info/entry_points.txt +2 -0
- yoru_cli-0.1.0.dist-info/licenses/LICENSE +31 -0
yoru_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
yoru_cli/__main__.py
ADDED
yoru_cli/api.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
)
|
yoru_cli/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from . import config, doctor_cmd, init_cmd, tail_cmd
|
|
7
|
+
|
|
8
|
+
DEFAULT_SERVER = "https://api.yoru.sh"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="yoru",
|
|
14
|
+
description="Yoru — audit-grade session receipts for autonomous AI coding agents.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--version",
|
|
18
|
+
action="version",
|
|
19
|
+
version=f"yoru {__version__}",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
subparsers = parser.add_subparsers(dest="cmd", required=True, metavar="{init,tail,doctor}")
|
|
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("--server", default=DEFAULT_SERVER, help=f"Backend URL (default: {DEFAULT_SERVER})")
|
|
29
|
+
p_init.add_argument(
|
|
30
|
+
"--token",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Pre-minted hook token (rcpt_...) — for headless/CI/server setups. "
|
|
33
|
+
"Also read from $YORU_TOKEN. Without this, yoru init launches "
|
|
34
|
+
"interactive device pairing.",
|
|
35
|
+
)
|
|
36
|
+
p_init.add_argument(
|
|
37
|
+
"--label",
|
|
38
|
+
default=None,
|
|
39
|
+
help="Human-readable machine label shown in the dashboard "
|
|
40
|
+
"(default: <hostname> · <os>).",
|
|
41
|
+
)
|
|
42
|
+
p_init.add_argument(
|
|
43
|
+
"--no-browser",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Don't try to auto-open the pairing URL in a browser.",
|
|
46
|
+
)
|
|
47
|
+
p_init.add_argument("--force", action="store_true", help="Overwrite an existing install.")
|
|
48
|
+
|
|
49
|
+
p_tail = subparsers.add_parser(
|
|
50
|
+
"tail",
|
|
51
|
+
help="Read JSON events from stdin and POST them as a batch (dev/debug).",
|
|
52
|
+
)
|
|
53
|
+
p_tail.add_argument(
|
|
54
|
+
"--server",
|
|
55
|
+
default=None,
|
|
56
|
+
help=f"Backend URL (default: value from config, else {DEFAULT_SERVER}).",
|
|
57
|
+
)
|
|
58
|
+
p_tail.add_argument("--session-id", default=None, help="Session id to stamp on events missing one.")
|
|
59
|
+
|
|
60
|
+
subparsers.add_parser(
|
|
61
|
+
"doctor",
|
|
62
|
+
help="Diagnose the install: config, backend, token, hook.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
parser = _build_parser()
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
|
|
72
|
+
if args.cmd == "tail" and args.server is None:
|
|
73
|
+
cfg = config.load() or {}
|
|
74
|
+
args.server = cfg.get("server", DEFAULT_SERVER)
|
|
75
|
+
|
|
76
|
+
if args.cmd == "init":
|
|
77
|
+
return init_cmd.run(args)
|
|
78
|
+
if args.cmd == "tail":
|
|
79
|
+
return tail_cmd.run(args)
|
|
80
|
+
if args.cmd == "doctor":
|
|
81
|
+
return doctor_cmd.run(args)
|
|
82
|
+
|
|
83
|
+
parser.error(f"unknown command: {args.cmd!r}")
|
|
84
|
+
return 2
|
yoru_cli/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _config_dir() -> Path:
|
|
11
|
+
return Path.home() / ".config" / "yoru"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _config_file() -> Path:
|
|
15
|
+
return _config_dir() / "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CONFIG_DIR: Path = _config_dir()
|
|
19
|
+
CONFIG_FILE: Path = _config_file()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exists() -> bool:
|
|
23
|
+
return _config_file().is_file()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load() -> dict[str, Any] | None:
|
|
27
|
+
path = _config_file()
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
return None
|
|
30
|
+
with path.open("r", encoding="utf-8") as f:
|
|
31
|
+
return json.load(f)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(data: dict[str, Any]) -> None:
|
|
35
|
+
dir_path = _config_dir()
|
|
36
|
+
file_path = _config_file()
|
|
37
|
+
os.makedirs(dir_path, mode=0o700, exist_ok=True)
|
|
38
|
+
payload = dict(data)
|
|
39
|
+
payload.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
|
40
|
+
fd = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
41
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
42
|
+
json.dump(payload, f, indent=2, sort_keys=True)
|
|
43
|
+
f.write("\n")
|
yoru_cli/doctor_cmd.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`yoru doctor` — diagnostic subcommand.
|
|
2
|
+
|
|
3
|
+
Read-only check of the install:
|
|
4
|
+
1. config.json present → else exit 1
|
|
5
|
+
2. backend /health/ready reachable → else exit 2
|
|
6
|
+
3. hook-token valid (GET /hook-tokens) → else exit 3 on 401
|
|
7
|
+
4. ~/.claude/hooks/yoru.sh is 0755 → else exit 4
|
|
8
|
+
|
|
9
|
+
Prints ✓ lines to stdout on success (exit 0). Failures go to stderr with a
|
|
10
|
+
short human-readable reason. No fixes attempted.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from . import config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _token_suffix(token: str) -> str:
|
|
26
|
+
tail = token[-4:] if len(token) >= 4 else token
|
|
27
|
+
return f"rcpt_...{tail}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _hook_path() -> Path:
|
|
31
|
+
return Path.home() / ".claude" / "hooks" / "yoru.sh"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(args: argparse.Namespace) -> int: # noqa: ARG001 — argparse hands args, unused for now
|
|
35
|
+
# 1. config
|
|
36
|
+
cfg = config.load()
|
|
37
|
+
if cfg is None:
|
|
38
|
+
print("yoru init not run", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
server = (cfg.get("server") or "").rstrip("/")
|
|
41
|
+
token = cfg.get("token") or ""
|
|
42
|
+
if not server or not token:
|
|
43
|
+
print("yoru init not run", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
# 2. backend /health/ready
|
|
47
|
+
try:
|
|
48
|
+
r = httpx.get(f"{server}/health/ready", timeout=5.0)
|
|
49
|
+
except httpx.HTTPError:
|
|
50
|
+
print(f"backend unreachable at {server}", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
if r.status_code != 200:
|
|
53
|
+
print(f"backend unreachable at {server}", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
# 3. hook-token validity
|
|
57
|
+
try:
|
|
58
|
+
r = httpx.get(
|
|
59
|
+
f"{server}/api/v1/auth/hook-tokens",
|
|
60
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
61
|
+
timeout=5.0,
|
|
62
|
+
)
|
|
63
|
+
except httpx.HTTPError:
|
|
64
|
+
print(f"backend unreachable at {server}", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
if r.status_code == 401:
|
|
67
|
+
print("token revoked or expired", file=sys.stderr)
|
|
68
|
+
return 3
|
|
69
|
+
if r.status_code != 200:
|
|
70
|
+
print(
|
|
71
|
+
f"hook-token check failed: HTTP {r.status_code}",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
return 3
|
|
75
|
+
|
|
76
|
+
# 4. hook file + perms
|
|
77
|
+
hook = _hook_path()
|
|
78
|
+
if not hook.is_file():
|
|
79
|
+
print("hook file missing or not 0755", file=sys.stderr)
|
|
80
|
+
return 4
|
|
81
|
+
mode = stat.S_IMODE(hook.stat().st_mode)
|
|
82
|
+
if mode != 0o755:
|
|
83
|
+
print("hook file missing or not 0755", file=sys.stderr)
|
|
84
|
+
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
|
+
print("\u2713 hook installed at ~/.claude/hooks/yoru.sh")
|
|
92
|
+
return 0
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Bundled Claude Code hook script — written verbatim by `yoru init`.
|
|
2
|
+
|
|
3
|
+
Shape is frozen in vault/CLI-V0-DESIGN.md §4. Bash (not Python) for fast startup;
|
|
4
|
+
`curl --max-time 2 || true` keeps the hook from ever blocking the agent.
|
|
5
|
+
v0 posts one event per tool call — batching is a v1 optimization.
|
|
6
|
+
|
|
7
|
+
Subscribed hook events (configured in ~/.claude/settings.json):
|
|
8
|
+
- SessionStart → kind=session_start
|
|
9
|
+
- UserPromptSubmit → kind=message (prompt text captured)
|
|
10
|
+
- PostToolUse → kind inferred (tool_use | file_change)
|
|
11
|
+
- Notification → kind=message (permission/input prompts)
|
|
12
|
+
- Stop → kind=session_end
|
|
13
|
+
- SubagentStop → kind=message (lightweight, doesn't close session)
|
|
14
|
+
|
|
15
|
+
PreToolUse is NOT subscribed — PostToolUse carries the same tool_input plus
|
|
16
|
+
tool_response, so subscribing to both doubles traffic for no gain.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
HOOK_SCRIPT: str = """#!/usr/bin/env bash
|
|
20
|
+
# Claude Code hook — Receipt ingest. Handles all subscribed hook events.
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
# Skip events when AGENT_RELAY_CHILD=1 (agent-relay-spawned children — prevents dashboard noise)
|
|
23
|
+
[ "${AGENT_RELAY_CHILD:-0}" = "1" ] && exit 0
|
|
24
|
+
CFG="${HOME}/.config/yoru/config.json"
|
|
25
|
+
[ -r "$CFG" ] || exit 0 # silent no-op if uninstalled
|
|
26
|
+
SERVER=$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.config/yoru/config.json")))["server"])')
|
|
27
|
+
TOKEN=$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.config/yoru/config.json")))["token"])')
|
|
28
|
+
# Claude Code pipes the hook event as JSON on stdin. We parse the original
|
|
29
|
+
# payload, attach it verbatim to `raw` (so the backend sees tool_input /
|
|
30
|
+
# tool_response — Pydantic drops unknown top-level fields otherwise), then
|
|
31
|
+
# mutate the top-level envelope with `kind` + extracted `content` for the
|
|
32
|
+
# renderer-friendly shape.
|
|
33
|
+
#
|
|
34
|
+
# Routing context (Phase C): hook detects cwd + git remote/branch and ships
|
|
35
|
+
# them with every event so the server can route the session to the right
|
|
36
|
+
# workspace. `git` calls are cached per-session in $TMPDIR to keep the hook
|
|
37
|
+
# fast on hot PostToolUse paths.
|
|
38
|
+
BODY=$(python3 -c 'import sys,json,os,subprocess
|
|
39
|
+
original=json.loads(sys.stdin.read())
|
|
40
|
+
e=dict(original)
|
|
41
|
+
e["raw"]=original
|
|
42
|
+
|
|
43
|
+
# Routing context — cwd from Claude payload (reliable), git info cached.
|
|
44
|
+
cwd = original.get("cwd")
|
|
45
|
+
if isinstance(cwd, str) and cwd:
|
|
46
|
+
e["cwd"] = cwd
|
|
47
|
+
|
|
48
|
+
sess_id = original.get("session_id") or original.get("sessionId") or ""
|
|
49
|
+
cache_dir = os.environ.get("TMPDIR", "/tmp")
|
|
50
|
+
cache = os.path.join(cache_dir, f".receipt-ctx-{sess_id}.env") if sess_id else None
|
|
51
|
+
git_remote = None
|
|
52
|
+
git_branch = None
|
|
53
|
+
if cache and os.path.exists(cache):
|
|
54
|
+
try:
|
|
55
|
+
for line in open(cache, encoding="utf-8"):
|
|
56
|
+
k,_,v = line.strip().partition("=")
|
|
57
|
+
if k == "git_remote": git_remote = v or None
|
|
58
|
+
elif k == "git_branch": git_branch = v or None
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
if (git_remote is None or git_branch is None) and isinstance(cwd, str) and cwd:
|
|
62
|
+
def _run(args):
|
|
63
|
+
try:
|
|
64
|
+
return subprocess.check_output(args, cwd=cwd, stderr=subprocess.DEVNULL, timeout=1).decode().strip() or None
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
if git_remote is None:
|
|
68
|
+
git_remote = _run(["git","remote","get-url","origin"])
|
|
69
|
+
if git_branch is None:
|
|
70
|
+
git_branch = _run(["git","rev-parse","--abbrev-ref","HEAD"])
|
|
71
|
+
if cache:
|
|
72
|
+
try:
|
|
73
|
+
with open(cache, "w", encoding="utf-8") as f:
|
|
74
|
+
if git_remote: f.write(f"git_remote={git_remote}\\n")
|
|
75
|
+
if git_branch: f.write(f"git_branch={git_branch}\\n")
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
if git_remote: e["git_remote"] = git_remote
|
|
79
|
+
if git_branch: e["git_branch"] = git_branch
|
|
80
|
+
|
|
81
|
+
hen=e.get("hook_event_name")
|
|
82
|
+
if hen=="SessionStart":
|
|
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
|
+
elif hen=="Stop":
|
|
99
|
+
e["kind"]="session_end"
|
|
100
|
+
# PostToolUse / PreToolUse: leave kind unset → backend _infer_kind() from tool
|
|
101
|
+
print(json.dumps({"events":[e]}))')
|
|
102
|
+
curl -sS --max-time 2 -X POST "${SERVER}/api/v1/sessions/events" \\
|
|
103
|
+
-H "Authorization: Bearer ${TOKEN}" \\
|
|
104
|
+
-H "Content-Type: application/json" \\
|
|
105
|
+
-d "${BODY}" >/dev/null 2>&1 || true # never block the agent
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Hook subscriptions to write into ~/.claude/settings.json.
|
|
110
|
+
# Each entry is a (hook_event_name, description) pair; the installer writes a
|
|
111
|
+
# single `matcher:"*"` entry per event pointing at ~/.claude/hooks/yoru.sh.
|
|
112
|
+
HOOK_SUBSCRIPTIONS: list[tuple[str, str]] = [
|
|
113
|
+
("SessionStart", "capture session boundary"),
|
|
114
|
+
("UserPromptSubmit", "capture user prompts (message events)"),
|
|
115
|
+
("PostToolUse", "capture tool_use + file_change"),
|
|
116
|
+
("Notification", "capture permission/input prompts"),
|
|
117
|
+
("Stop", "capture session close"),
|
|
118
|
+
("SubagentStop", "capture subagent lifecycle"),
|
|
119
|
+
]
|
yoru_cli/init_cmd.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
from .api import ReceiptClient
|
|
18
|
+
from .hook_template import HOOK_SCRIPT
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_label() -> str:
|
|
22
|
+
"""Best-effort human label for this machine — 'macbook-air · darwin'."""
|
|
23
|
+
host = socket.gethostname().split(".")[0] or "unknown"
|
|
24
|
+
return f"{host} · {platform.system().lower()}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
RECEIPT_MATCHERS: list[tuple[str, str]] = [
|
|
28
|
+
("PostToolUse", "*"),
|
|
29
|
+
("SessionStart", "*"),
|
|
30
|
+
("Stop", "*"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _merge_settings_json(settings_path: Path, hook_path: Path) -> None:
|
|
35
|
+
"""Register the receipt hook in ~/.claude/settings.json, preserving user keys.
|
|
36
|
+
|
|
37
|
+
Registers PostToolUse + SessionStart + Stop so the timeline has bookends.
|
|
38
|
+
"""
|
|
39
|
+
if settings_path.exists():
|
|
40
|
+
try:
|
|
41
|
+
obj = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
42
|
+
except json.JSONDecodeError:
|
|
43
|
+
obj = {}
|
|
44
|
+
if not isinstance(obj, dict):
|
|
45
|
+
obj = {}
|
|
46
|
+
else:
|
|
47
|
+
obj = {}
|
|
48
|
+
|
|
49
|
+
hooks = obj.setdefault("hooks", {})
|
|
50
|
+
if not isinstance(hooks, dict):
|
|
51
|
+
hooks = {}
|
|
52
|
+
obj["hooks"] = hooks
|
|
53
|
+
|
|
54
|
+
def _is_receipt_entry(entry: object) -> bool:
|
|
55
|
+
if not isinstance(entry, dict):
|
|
56
|
+
return False
|
|
57
|
+
inner = entry.get("hooks")
|
|
58
|
+
if not isinstance(inner, list) or not inner:
|
|
59
|
+
return False
|
|
60
|
+
first = inner[0]
|
|
61
|
+
if not isinstance(first, dict):
|
|
62
|
+
return False
|
|
63
|
+
cmd = first.get("command")
|
|
64
|
+
return isinstance(cmd, str) and cmd.endswith("yoru.sh")
|
|
65
|
+
|
|
66
|
+
for event_name, matcher_glob in RECEIPT_MATCHERS:
|
|
67
|
+
entries = hooks.setdefault(event_name, [])
|
|
68
|
+
if not isinstance(entries, list):
|
|
69
|
+
entries = []
|
|
70
|
+
hooks[event_name] = entries
|
|
71
|
+
|
|
72
|
+
receipt_entry = {
|
|
73
|
+
"matcher": matcher_glob,
|
|
74
|
+
"hooks": [{"type": "command", "command": str(hook_path)}],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
replaced = False
|
|
78
|
+
for idx, entry in enumerate(entries):
|
|
79
|
+
if _is_receipt_entry(entry):
|
|
80
|
+
entries[idx] = receipt_entry
|
|
81
|
+
replaced = True
|
|
82
|
+
break
|
|
83
|
+
if not replaced:
|
|
84
|
+
entries.append(receipt_entry)
|
|
85
|
+
|
|
86
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
tmp = settings_path.with_suffix(".json.tmp")
|
|
88
|
+
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
|
89
|
+
os.replace(tmp, settings_path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _pair_device(server: str, label: str, *, no_browser: bool) -> str | None:
|
|
93
|
+
"""Run the device-code pairing handshake — returns the raw token or None."""
|
|
94
|
+
client = ReceiptClient(server)
|
|
95
|
+
try:
|
|
96
|
+
start = client.start_device_code(label=label)
|
|
97
|
+
except httpx.HTTPError as e:
|
|
98
|
+
print(f"error: failed to contact {server}: {e}", file=sys.stderr)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
user_code = start["user_code"]
|
|
102
|
+
verify_uri = start["verification_uri"]
|
|
103
|
+
verify_complete = start["verification_uri_complete"]
|
|
104
|
+
device_code = start["device_code"]
|
|
105
|
+
expires_in = int(start.get("expires_in", 600))
|
|
106
|
+
interval = int(start.get("interval", 2))
|
|
107
|
+
|
|
108
|
+
print()
|
|
109
|
+
print(f" Pair this device with your Yoru account:")
|
|
110
|
+
print(f" 1. Open {verify_uri}")
|
|
111
|
+
print(f" 2. Enter {user_code}")
|
|
112
|
+
print()
|
|
113
|
+
if not no_browser:
|
|
114
|
+
try:
|
|
115
|
+
webbrowser.open(verify_complete)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
deadline = time.time() + expires_in
|
|
120
|
+
while time.time() < deadline:
|
|
121
|
+
try:
|
|
122
|
+
resp = client.poll_device_code(device_code)
|
|
123
|
+
except httpx.HTTPError as e:
|
|
124
|
+
print(f"\nerror: poll failed: {e}", file=sys.stderr)
|
|
125
|
+
return None
|
|
126
|
+
s = resp.get("status")
|
|
127
|
+
if s == "approved":
|
|
128
|
+
token = resp.get("token")
|
|
129
|
+
if not token:
|
|
130
|
+
print("error: approved but no token returned", file=sys.stderr)
|
|
131
|
+
return None
|
|
132
|
+
print(f" ✓ Paired as {label}")
|
|
133
|
+
return token
|
|
134
|
+
if s in ("expired", "denied"):
|
|
135
|
+
print(f"\nerror: pairing {s} — re-run `yoru init`", file=sys.stderr)
|
|
136
|
+
return None
|
|
137
|
+
# pending — sleep and keep polling
|
|
138
|
+
sys.stdout.write(" waiting for approval…\r")
|
|
139
|
+
sys.stdout.flush()
|
|
140
|
+
time.sleep(interval)
|
|
141
|
+
|
|
142
|
+
print("\nerror: pairing timed out — re-run `yoru init`", file=sys.stderr)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run(args: argparse.Namespace) -> int:
|
|
147
|
+
if config.exists() and not getattr(args, "force", False):
|
|
148
|
+
print("Already installed (use --force to overwrite)", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
server: str = args.server
|
|
152
|
+
token: str | None = getattr(args, "token", None)
|
|
153
|
+
# Also accept YORU_TOKEN from env for headless / CI / server deployments.
|
|
154
|
+
if not token:
|
|
155
|
+
token = os.environ.get("YORU_TOKEN", "").strip() or None
|
|
156
|
+
|
|
157
|
+
if not token:
|
|
158
|
+
label = (getattr(args, "label", None) or "").strip() or _default_label()
|
|
159
|
+
no_browser = bool(getattr(args, "no_browser", False))
|
|
160
|
+
token = _pair_device(server, label, no_browser=no_browser)
|
|
161
|
+
if not token:
|
|
162
|
+
return 2
|
|
163
|
+
|
|
164
|
+
config.save({
|
|
165
|
+
"server": server,
|
|
166
|
+
"token": token,
|
|
167
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
168
|
+
})
|
|
169
|
+
|
|
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)
|
|
178
|
+
|
|
179
|
+
print("\u2713 config \u2192 ~/.config/yoru/config.json")
|
|
180
|
+
print("\u2713 hook \u2192 ~/.claude/hooks/yoru.sh")
|
|
181
|
+
print("\u2713 settings \u2192 ~/.claude/settings.json (hook registered)")
|
|
182
|
+
print("Next: run Claude Code normally; first event streams to /sessions/events.")
|
|
183
|
+
return 0
|
yoru_cli/tail_cmd.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from . import config
|
|
11
|
+
from .api import ReceiptClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(args: argparse.Namespace) -> int:
|
|
15
|
+
cfg = config.load()
|
|
16
|
+
server = getattr(args, "server", None) or (cfg.get("server") if cfg else None)
|
|
17
|
+
if not server:
|
|
18
|
+
print("error: no server configured (run `yoru init` or pass --server)", file=sys.stderr)
|
|
19
|
+
return 2
|
|
20
|
+
token = cfg.get("token") if cfg else None
|
|
21
|
+
|
|
22
|
+
raw = sys.stdin.read().strip()
|
|
23
|
+
if not raw:
|
|
24
|
+
print("error: no input on stdin", file=sys.stderr)
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
events: list[dict[str, Any]]
|
|
28
|
+
if raw.lstrip().startswith("["):
|
|
29
|
+
events = json.loads(raw)
|
|
30
|
+
else:
|
|
31
|
+
events = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
|
32
|
+
|
|
33
|
+
session_id = getattr(args, "session_id", None)
|
|
34
|
+
if session_id:
|
|
35
|
+
for e in events:
|
|
36
|
+
e["session_id"] = session_id
|
|
37
|
+
|
|
38
|
+
client = ReceiptClient(server, token)
|
|
39
|
+
try:
|
|
40
|
+
resp = client.post_events(events)
|
|
41
|
+
except httpx.HTTPError as e:
|
|
42
|
+
print(f"error: {e}", file=sys.stderr)
|
|
43
|
+
return 4
|
|
44
|
+
|
|
45
|
+
print(f"HTTP {resp.status_code}")
|
|
46
|
+
print(resp.text[:500])
|
|
47
|
+
if 200 <= resp.status_code < 300:
|
|
48
|
+
return 0
|
|
49
|
+
if 400 <= resp.status_code < 500:
|
|
50
|
+
return 3
|
|
51
|
+
return 4
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Transcript tailer — captures AssistantMessage events that hooks can't.
|
|
2
|
+
|
|
3
|
+
Claude Code hooks expose user prompts, tool calls, and session lifecycle, but
|
|
4
|
+
emit no event when Claude itself speaks. For a complete audit trail Receipt
|
|
5
|
+
must read the transcript file (`~/.claude/projects/<slug>/<session>.jsonl`),
|
|
6
|
+
which Claude Code appends to in real time with every message (user, assistant,
|
|
7
|
+
tool_use, tool_result).
|
|
8
|
+
|
|
9
|
+
Strategy:
|
|
10
|
+
* Discover all `.jsonl` under `~/.claude/projects/` on startup + via 2-sec
|
|
11
|
+
rescan for new sessions.
|
|
12
|
+
* Per file, remember the last byte offset in a small state file so a restart
|
|
13
|
+
never reprocesses.
|
|
14
|
+
* On every new JSONL line, route:
|
|
15
|
+
type=assistant + content[].type=text → kind=message tool=assistant
|
|
16
|
+
type=assistant + content[].type=thinking → kind=message tool=thinking
|
|
17
|
+
(user prompts + tool calls + tool results are already captured by hooks.)
|
|
18
|
+
* POST one event at a time via the same /api/v1/sessions/events endpoint.
|
|
19
|
+
|
|
20
|
+
Daemonize: run as LaunchAgent (`launchctl load …plist`) or in a tmux pane.
|
|
21
|
+
|
|
22
|
+
v0 keeps it dependency-free — stdlib-only, one thread per file.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Iterable
|
|
34
|
+
|
|
35
|
+
_PROJECTS_DIR = Path.home() / ".claude/projects"
|
|
36
|
+
_CONFIG_PATH = Path.home() / ".config/yoru/config.json"
|
|
37
|
+
_STATE_PATH = Path.home() / ".config/yoru/tail-state.json"
|
|
38
|
+
_POLL_INTERVAL_SEC = 1.0
|
|
39
|
+
_RESCAN_INTERVAL_SEC = 5.0
|
|
40
|
+
|
|
41
|
+
# Pricing is computed backend-side (see backend/apps/api/api/routers/receipt/
|
|
42
|
+
# pricing.py which auto-refreshes from LiteLLM's public JSON). The tailer
|
|
43
|
+
# ships raw usage + model and lets the backend resolve the rate so we never
|
|
44
|
+
# have stale hardcoded prices here — and new providers (Cursor, Aider,
|
|
45
|
+
# whatever emits OpenAI/Gemini) work without touching the tailer.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_config() -> tuple[str, str]:
|
|
49
|
+
with open(_CONFIG_PATH) as f:
|
|
50
|
+
cfg = json.load(f)
|
|
51
|
+
return cfg["server"].rstrip("/"), cfg["token"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_state() -> dict[str, int]:
|
|
55
|
+
if not _STATE_PATH.exists():
|
|
56
|
+
return {}
|
|
57
|
+
try:
|
|
58
|
+
with open(_STATE_PATH) as f:
|
|
59
|
+
return json.load(f)
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _save_state(state: dict[str, int]) -> None:
|
|
65
|
+
_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
tmp = _STATE_PATH.with_suffix(".tmp")
|
|
67
|
+
with open(tmp, "w") as f:
|
|
68
|
+
json.dump(state, f)
|
|
69
|
+
tmp.replace(_STATE_PATH)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _post(server: str, token: str, event: dict[str, Any]) -> None:
|
|
73
|
+
"""One-event ingest. Silent on non-2xx; we never block on Yoru outages."""
|
|
74
|
+
_post_batch(server, token, [event])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _post_batch(server: str, token: str, events: list[dict[str, Any]]) -> None:
|
|
78
|
+
"""Batch ingest — POST up to 1000 events in one request. Respects the
|
|
79
|
+
Retry-After header returned by the backend's token-bucket rate limiter
|
|
80
|
+
(slowapi-style), so a long backfill throttles instead of losing events."""
|
|
81
|
+
if not events:
|
|
82
|
+
return
|
|
83
|
+
body = json.dumps({"events": events}).encode("utf-8")
|
|
84
|
+
for attempt in range(6):
|
|
85
|
+
req = urllib.request.Request(
|
|
86
|
+
f"{server}/api/v1/sessions/events",
|
|
87
|
+
data=body,
|
|
88
|
+
headers={
|
|
89
|
+
"Authorization": f"Bearer {token}",
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
},
|
|
92
|
+
method="POST",
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
urllib.request.urlopen(req, timeout=30).read()
|
|
96
|
+
return
|
|
97
|
+
except urllib.error.HTTPError as e:
|
|
98
|
+
if e.code == 429:
|
|
99
|
+
retry_after = 1
|
|
100
|
+
try:
|
|
101
|
+
retry_after = int(e.headers.get("Retry-After", "1"))
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
pass
|
|
104
|
+
time.sleep(retry_after + 0.2)
|
|
105
|
+
continue
|
|
106
|
+
print(f"[tailer] POST {e.code}: {e.reason}", file=sys.stderr)
|
|
107
|
+
return
|
|
108
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
109
|
+
print(f"[tailer] POST failed: {e}", file=sys.stderr)
|
|
110
|
+
return
|
|
111
|
+
print(f"[tailer] gave up after 6 retries (rate-limited)", file=sys.stderr)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# In-memory set of Anthropic message IDs we've already ingested. Prevents
|
|
115
|
+
# Claude Code's occasional duplicate transcript writes (streaming deltas +
|
|
116
|
+
# final consolidation, or a session compaction replaying history) from
|
|
117
|
+
# double-counting tokens and cost. The set grows unboundedly in a long run
|
|
118
|
+
# but each entry is ~40 bytes so millions fit comfortably.
|
|
119
|
+
_SEEN_MSG_IDS: set[str] = set()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _iter_assistant_events(line: str) -> Iterable[dict[str, Any]]:
|
|
123
|
+
"""Parse one JSONL line, yield Receipt events.
|
|
124
|
+
|
|
125
|
+
Handles two line types:
|
|
126
|
+
* type=user → emits kind=message tool=user (the hook UserPromptSubmit
|
|
127
|
+
only fires live, so backfill has to replay prompts from transcript)
|
|
128
|
+
* type=assistant → emits:
|
|
129
|
+
- kind=message tool=assistant per text block
|
|
130
|
+
- kind=message tool=thinking per thinking block
|
|
131
|
+
- kind=token per assistant message (usage rollup → cost)
|
|
132
|
+
|
|
133
|
+
Returns nothing for other types (attachment/snapshot/etc) or for message
|
|
134
|
+
IDs we've already processed this session.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
d = json.loads(line)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
return
|
|
140
|
+
t = d.get("type")
|
|
141
|
+
if t == "user":
|
|
142
|
+
# User prompt replay for backfill. Live tailer also emits these (dup
|
|
143
|
+
# with the hook) but the hook-sourced events have no `message.id`
|
|
144
|
+
# from the transcript — they're deduped by session_id+ts pair on
|
|
145
|
+
# the backend if anyone cares, which for now we don't (duplicates
|
|
146
|
+
# under kind=message tool=user are cosmetic in the timeline).
|
|
147
|
+
msg = d.get("message") or {}
|
|
148
|
+
content = msg.get("content")
|
|
149
|
+
# Claude Code writes either a string content ("hi") or an array of
|
|
150
|
+
# blocks ([{type:text,text:"hi"}, {type:tool_result,...}]). We only
|
|
151
|
+
# keep the text blocks, join them.
|
|
152
|
+
text = ""
|
|
153
|
+
if isinstance(content, str):
|
|
154
|
+
text = content
|
|
155
|
+
elif isinstance(content, list):
|
|
156
|
+
parts = [b.get("text") for b in content
|
|
157
|
+
if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str)]
|
|
158
|
+
text = "\n".join(p for p in parts if p)
|
|
159
|
+
text = (text or "").strip()
|
|
160
|
+
if not text:
|
|
161
|
+
return
|
|
162
|
+
session_id = d.get("sessionId") or ""
|
|
163
|
+
ts = d.get("timestamp") or ""
|
|
164
|
+
if not session_id:
|
|
165
|
+
return
|
|
166
|
+
# Dedup: a message.id-style key would be ideal but user lines don't
|
|
167
|
+
# always carry one; use (uuid) from the line which is transcript-
|
|
168
|
+
# unique.
|
|
169
|
+
key = d.get("uuid")
|
|
170
|
+
if isinstance(key, str) and key:
|
|
171
|
+
if key in _SEEN_MSG_IDS:
|
|
172
|
+
return
|
|
173
|
+
_SEEN_MSG_IDS.add(key)
|
|
174
|
+
yield {
|
|
175
|
+
"session_id": session_id,
|
|
176
|
+
"ts": ts,
|
|
177
|
+
"kind": "message",
|
|
178
|
+
"tool": "user",
|
|
179
|
+
"content": text[:2000],
|
|
180
|
+
"raw": {"hook_event_name": "TranscriptTail", "uuid": key},
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
if t != "assistant":
|
|
185
|
+
return
|
|
186
|
+
message = d.get("message") or {}
|
|
187
|
+
content = message.get("content") or []
|
|
188
|
+
if not isinstance(content, list):
|
|
189
|
+
return
|
|
190
|
+
session_id = d.get("sessionId") or ""
|
|
191
|
+
ts = d.get("timestamp") or ""
|
|
192
|
+
if not session_id:
|
|
193
|
+
return
|
|
194
|
+
# Dedup by Anthropic message.id. First-seen wins; later dupes silently
|
|
195
|
+
# dropped. Avoids counting the same assistant turn 2+ times.
|
|
196
|
+
msg_id = message.get("id")
|
|
197
|
+
if isinstance(msg_id, str) and msg_id:
|
|
198
|
+
if msg_id in _SEEN_MSG_IDS:
|
|
199
|
+
return
|
|
200
|
+
_SEEN_MSG_IDS.add(msg_id)
|
|
201
|
+
for block in content:
|
|
202
|
+
if not isinstance(block, dict):
|
|
203
|
+
continue
|
|
204
|
+
block_type = block.get("type")
|
|
205
|
+
if block_type == "text":
|
|
206
|
+
text = block.get("text")
|
|
207
|
+
if isinstance(text, str) and text.strip():
|
|
208
|
+
yield {
|
|
209
|
+
"session_id": session_id,
|
|
210
|
+
"ts": ts,
|
|
211
|
+
"kind": "message",
|
|
212
|
+
"tool": "assistant",
|
|
213
|
+
"content": text[:4000],
|
|
214
|
+
"raw": {"hook_event_name": "TranscriptTail", "block": block},
|
|
215
|
+
}
|
|
216
|
+
elif block_type == "thinking":
|
|
217
|
+
think = block.get("thinking")
|
|
218
|
+
if isinstance(think, str) and think.strip():
|
|
219
|
+
yield {
|
|
220
|
+
"session_id": session_id,
|
|
221
|
+
"ts": ts,
|
|
222
|
+
"kind": "message",
|
|
223
|
+
"tool": "thinking",
|
|
224
|
+
"content": think[:4000],
|
|
225
|
+
"raw": {"hook_event_name": "TranscriptTail", "block": block},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# One usage event per message — the backend's events_router aggregates
|
|
229
|
+
# tokens_input/output/cost_usd onto the session row, so this is where the
|
|
230
|
+
# Hero "cost" sparkline gets its numbers. Attaching model to `raw` keeps
|
|
231
|
+
# the assumption auditable.
|
|
232
|
+
usage = message.get("usage") or {}
|
|
233
|
+
if isinstance(usage, dict) and usage:
|
|
234
|
+
model = str(message.get("model") or "")
|
|
235
|
+
input_tokens = (
|
|
236
|
+
int(usage.get("input_tokens") or 0)
|
|
237
|
+
+ int(usage.get("cache_read_input_tokens") or 0)
|
|
238
|
+
+ int(usage.get("cache_creation_input_tokens") or 0)
|
|
239
|
+
)
|
|
240
|
+
output_tokens = int(usage.get("output_tokens") or 0)
|
|
241
|
+
if input_tokens > 0 or output_tokens > 0:
|
|
242
|
+
# Put the full usage breakdown under `raw.tool_input` so the
|
|
243
|
+
# backend's existing _enrich_events path (`raw.tool_input` →
|
|
244
|
+
# `EventOut.tool_input`) surfaces it to the frontend TokenPanel
|
|
245
|
+
# without a schema change. The backend's pricing compute reads
|
|
246
|
+
# `raw.usage` for cost calculation — we duplicate to keep both
|
|
247
|
+
# paths working.
|
|
248
|
+
yield {
|
|
249
|
+
"session_id": session_id,
|
|
250
|
+
"ts": ts,
|
|
251
|
+
"kind": "token",
|
|
252
|
+
"tool": model or "usage",
|
|
253
|
+
"tokens_input": input_tokens,
|
|
254
|
+
"tokens_output": output_tokens,
|
|
255
|
+
"content": f"{model} · {input_tokens}→{output_tokens} tok",
|
|
256
|
+
"raw": {
|
|
257
|
+
"hook_event_name": "TranscriptTail",
|
|
258
|
+
"model": model,
|
|
259
|
+
"usage": usage,
|
|
260
|
+
"tool_input": {"model": model, **usage},
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _drain_file(
|
|
266
|
+
path: Path,
|
|
267
|
+
offset: int,
|
|
268
|
+
server: str,
|
|
269
|
+
token: str,
|
|
270
|
+
) -> int:
|
|
271
|
+
"""Read from `offset` to EOF, POSTing each derived event. Returns new offset."""
|
|
272
|
+
try:
|
|
273
|
+
with open(path, "rb") as f:
|
|
274
|
+
f.seek(offset)
|
|
275
|
+
remainder = f.read()
|
|
276
|
+
except OSError as e:
|
|
277
|
+
print(f"[tailer] read {path}: {e}", file=sys.stderr)
|
|
278
|
+
return offset
|
|
279
|
+
if not remainder:
|
|
280
|
+
return offset
|
|
281
|
+
# If we caught a partial line at EOF, rewind to the last newline.
|
|
282
|
+
last_nl = remainder.rfind(b"\n")
|
|
283
|
+
if last_nl == -1:
|
|
284
|
+
return offset # no complete line yet
|
|
285
|
+
consumed, pending = remainder[: last_nl + 1], remainder[last_nl + 1 :]
|
|
286
|
+
new_offset = offset + len(consumed)
|
|
287
|
+
text = consumed.decode("utf-8", errors="replace")
|
|
288
|
+
for line in text.splitlines():
|
|
289
|
+
if not line:
|
|
290
|
+
continue
|
|
291
|
+
for ev in _iter_assistant_events(line):
|
|
292
|
+
_post(server, token, ev)
|
|
293
|
+
_ = pending # drop — next drain re-reads from new_offset
|
|
294
|
+
return new_offset
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def run() -> None:
|
|
298
|
+
server, token = _load_config()
|
|
299
|
+
state = _load_state()
|
|
300
|
+
last_rescan = 0.0
|
|
301
|
+
tracked: dict[str, Path] = {} # abs-path-str → Path
|
|
302
|
+
print(f"[tailer] server={server} watching {_PROJECTS_DIR}", file=sys.stderr)
|
|
303
|
+
while True:
|
|
304
|
+
now = time.time()
|
|
305
|
+
if now - last_rescan > _RESCAN_INTERVAL_SEC:
|
|
306
|
+
if _PROJECTS_DIR.is_dir():
|
|
307
|
+
for p in _PROJECTS_DIR.glob("**/*.jsonl"):
|
|
308
|
+
key = str(p)
|
|
309
|
+
if key not in tracked:
|
|
310
|
+
tracked[key] = p
|
|
311
|
+
# First-ever discovery: seek to END so we only pick up
|
|
312
|
+
# NEW assistant messages, never backfill (audit-safe —
|
|
313
|
+
# backfill would duplicate old messages already viewed
|
|
314
|
+
# from older CLI hooks).
|
|
315
|
+
if key not in state:
|
|
316
|
+
try:
|
|
317
|
+
state[key] = p.stat().st_size
|
|
318
|
+
except OSError:
|
|
319
|
+
state[key] = 0
|
|
320
|
+
last_rescan = now
|
|
321
|
+
|
|
322
|
+
any_change = False
|
|
323
|
+
for key, path in list(tracked.items()):
|
|
324
|
+
offset = state.get(key, 0)
|
|
325
|
+
new_offset = _drain_file(path, offset, server, token)
|
|
326
|
+
if new_offset != offset:
|
|
327
|
+
state[key] = new_offset
|
|
328
|
+
any_change = True
|
|
329
|
+
if any_change:
|
|
330
|
+
_save_state(state)
|
|
331
|
+
time.sleep(_POLL_INTERVAL_SEC)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def backfill(session_id: str, wipe: bool = True) -> None:
|
|
335
|
+
"""One-shot: re-ingest every assistant event for a given session.
|
|
336
|
+
|
|
337
|
+
Finds `~/.claude/projects/**/<session_id>.jsonl`, optionally DELETEs
|
|
338
|
+
existing tailer-origin events on the backend (avoids double-counting
|
|
339
|
+
tokens + cost), then replays the file from offset 0 through
|
|
340
|
+
`_iter_assistant_events` → POST /sessions/events.
|
|
341
|
+
|
|
342
|
+
Use cases:
|
|
343
|
+
* Import a session that started before the tailer was installed.
|
|
344
|
+
* Re-import after changing pricing rates (backend recomputes cost_usd
|
|
345
|
+
at ingest time from the current rate table).
|
|
346
|
+
|
|
347
|
+
Running it twice WITHOUT `wipe=True` will emit every event twice →
|
|
348
|
+
doubled aggregates. Default `wipe=True` is safe.
|
|
349
|
+
"""
|
|
350
|
+
server, token = _load_config()
|
|
351
|
+
# 1. locate the transcript
|
|
352
|
+
candidates = list(_PROJECTS_DIR.glob(f"**/{session_id}.jsonl"))
|
|
353
|
+
if not candidates:
|
|
354
|
+
print(f"[backfill] no transcript found for session {session_id}", file=sys.stderr)
|
|
355
|
+
return
|
|
356
|
+
path = candidates[0]
|
|
357
|
+
print(f"[backfill] using {path}", file=sys.stderr)
|
|
358
|
+
|
|
359
|
+
# 2. wipe existing tailer events server-side so aggregates stay honest
|
|
360
|
+
if wipe:
|
|
361
|
+
req = urllib.request.Request(
|
|
362
|
+
f"{server}/api/v1/sessions/{session_id}/tailer-events",
|
|
363
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
364
|
+
method="DELETE",
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
urllib.request.urlopen(req, timeout=10).read()
|
|
368
|
+
print(f"[backfill] wiped existing tailer events for {session_id}", file=sys.stderr)
|
|
369
|
+
except urllib.error.HTTPError as e:
|
|
370
|
+
print(f"[backfill] wipe returned {e.code} — continuing", file=sys.stderr)
|
|
371
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
372
|
+
print(f"[backfill] wipe failed: {e} — aborting to avoid dupes", file=sys.stderr)
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
# 3. replay the file — batch size kept small (50) because each event can
|
|
376
|
+
# carry a multi-kB `raw` payload (full tool_input + tool_response) and
|
|
377
|
+
# the backend has a body-size limit that rejects big batches with 413.
|
|
378
|
+
BATCH = 50
|
|
379
|
+
batch: list[dict[str, Any]] = []
|
|
380
|
+
count = 0
|
|
381
|
+
def _flush() -> None:
|
|
382
|
+
nonlocal batch, count
|
|
383
|
+
if not batch: return
|
|
384
|
+
_post_batch(server, token, batch)
|
|
385
|
+
count += len(batch)
|
|
386
|
+
batch = []
|
|
387
|
+
with open(path) as f:
|
|
388
|
+
for line in f:
|
|
389
|
+
for ev in _iter_assistant_events(line):
|
|
390
|
+
batch.append(ev)
|
|
391
|
+
if len(batch) >= BATCH:
|
|
392
|
+
_flush()
|
|
393
|
+
time.sleep(0.1) # breathing room for slowapi
|
|
394
|
+
_flush()
|
|
395
|
+
print(f"[backfill] emitted {count} events for session {session_id}", file=sys.stderr)
|
|
396
|
+
|
|
397
|
+
# 4. bump the saved offset to EOF so the live tailer doesn't re-emit
|
|
398
|
+
state = _load_state()
|
|
399
|
+
try:
|
|
400
|
+
state[str(path)] = path.stat().st_size
|
|
401
|
+
_save_state(state)
|
|
402
|
+
except OSError:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def backfill_all(wipe: bool = True) -> None:
|
|
407
|
+
"""Backfill every transcript under ~/.claude/projects/. Useful once."""
|
|
408
|
+
for path in _PROJECTS_DIR.glob("**/*.jsonl"):
|
|
409
|
+
sid = path.stem
|
|
410
|
+
print(f"[backfill-all] ▶ {sid}", file=sys.stderr)
|
|
411
|
+
backfill(sid, wipe=wipe)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if __name__ == "__main__":
|
|
415
|
+
import argparse
|
|
416
|
+
ap = argparse.ArgumentParser(
|
|
417
|
+
prog="yoru-tailer",
|
|
418
|
+
description="Stream Claude Code assistant messages into Yoru.",
|
|
419
|
+
)
|
|
420
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
421
|
+
sub.add_parser("run", help="follow every transcript live (default)")
|
|
422
|
+
bf = sub.add_parser("backfill", help="re-ingest one session's transcript")
|
|
423
|
+
bf.add_argument("session_id")
|
|
424
|
+
bf.add_argument("--no-wipe", action="store_true",
|
|
425
|
+
help="skip the DELETE tailer-events pre-step (will duplicate)")
|
|
426
|
+
bfa = sub.add_parser("backfill-all", help="re-ingest every transcript on disk")
|
|
427
|
+
bfa.add_argument("--no-wipe", action="store_true")
|
|
428
|
+
|
|
429
|
+
args = ap.parse_args()
|
|
430
|
+
try:
|
|
431
|
+
if args.cmd == "backfill":
|
|
432
|
+
backfill(args.session_id, wipe=not args.no_wipe)
|
|
433
|
+
elif args.cmd == "backfill-all":
|
|
434
|
+
backfill_all(wipe=not args.no_wipe)
|
|
435
|
+
else:
|
|
436
|
+
run()
|
|
437
|
+
except KeyboardInterrupt:
|
|
438
|
+
pass
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yoru-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Yoru — audit-grade session receipts for autonomous AI coding agents.
|
|
5
|
+
Project-URL: Homepage, https://yoru.sh
|
|
6
|
+
Project-URL: Documentation, https://yoru.sh/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/helios-code/overnight-saas
|
|
8
|
+
Project-URL: Issues, https://github.com/helios-code/overnight-saas/issues
|
|
9
|
+
Author-email: Yoru authors <hello@opentruth.ch>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,aider,audit,claude-code,cursor,observability
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# yoru-cli
|
|
28
|
+
|
|
29
|
+
Yoru — audit-grade session receipts for autonomous AI coding agents.
|
|
30
|
+
|
|
31
|
+
One command installs a Claude Code hook that streams every tool call into the Yoru backend; the dashboard turns that feed into a signed session receipt.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e . # from the monorepo
|
|
37
|
+
# or once published:
|
|
38
|
+
# pip install yoru-cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.10+. Only runtime dep is `httpx`.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
yoru init # writes ~/.claude/hooks/yoru.sh + ~/.config/yoru/config.json (0600)
|
|
47
|
+
yoru init --server http://localhost:8002 --user you@example.com # non-interactive (CI/smoke)
|
|
48
|
+
yoru init --server http://localhost:8002 --token rcpt_xxx --force
|
|
49
|
+
|
|
50
|
+
yoru tail # reads JSON events on stdin, POSTs them as a batch (dev/debug)
|
|
51
|
+
echo '{"session_id":"s1","user":"dev","kind":"tool_use","tool":"Bash"}' | yoru tail
|
|
52
|
+
|
|
53
|
+
receipt --version # receipt 0.1.0
|
|
54
|
+
receipt --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Exit codes: `0` ok, `1` already installed without `--force`, `2` auth failed, `3` 4xx, `4` 5xx/network.
|
|
58
|
+
|
|
59
|
+
## Spec
|
|
60
|
+
|
|
61
|
+
Frozen design doc: `vault/CLI-V0-DESIGN.md` in the monorepo (§1 layout, §2 pyproject, §3 subcommands, §4 hook shape, §5 auth, §7 event schema).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
yoru_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
yoru_cli/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
yoru_cli/api.py,sha256=Q29oFcpgg864UVqI276qPmYEtAmpDzeL7Y3smHejEBM,1341
|
|
4
|
+
yoru_cli/cli.py,sha256=-SZs5eUi7Rd8jI873yda1rJ8ke_En_k6lvdkfpYAvUA,2691
|
|
5
|
+
yoru_cli/config.py,sha256=u1UgK_UOsCaYWGg7DuGLej7IGA0NtPg6ahLQcymT0S0,1069
|
|
6
|
+
yoru_cli/doctor_cmd.py,sha256=A7Is43Ocv2dV_LiTCKqarPX7BCHUTqrmAmdA93y5QzQ,2854
|
|
7
|
+
yoru_cli/hook_template.py,sha256=R_77i16g9q1tNw0-8ZWfWxUip7UmnpeiM1gF5rJ63oE,5342
|
|
8
|
+
yoru_cli/init_cmd.py,sha256=ShQ6St6Wu74FJEB6mty1DO3PgJxUN9u-Q6EqVEgOzNc,6137
|
|
9
|
+
yoru_cli/tail_cmd.py,sha256=oWOsGR9LpuTKDRUARVfLAT2_SDPJZc0cp3jR76YMl9w,1334
|
|
10
|
+
yoru_cli/transcript_tailer.py,sha256=BUGp15953zIYVEd-fNqJRINGGrkFfypJHRAFtUFQSnw,17156
|
|
11
|
+
yoru_cli-0.1.0.dist-info/METADATA,sha256=LOeOcqWJrFXbfBtBZXfeP1VzeiKNIqFh4nPedSw_CgY,2280
|
|
12
|
+
yoru_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
yoru_cli-0.1.0.dist-info/entry_points.txt,sha256=PjwjTQLl09ujQ2fxl1oIRtZSmmRZwgkIK6bl-k2ivbU,43
|
|
14
|
+
yoru_cli-0.1.0.dist-info/licenses/LICENSE,sha256=wld15WFSfVpLvSbFRo8lPewGKHccwME11CYcN_JAgy8,1717
|
|
15
|
+
yoru_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yoru authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
─────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
SCOPE: The Yoru CLI — everything inside the `yoru-cli/` directory in
|
|
26
|
+
the Yoru monorepo. The CLI is intentionally MIT so it can be freely
|
|
27
|
+
embedded in proprietary dev environments, CI pipelines, internal tooling,
|
|
28
|
+
and closed-source forks without viral license obligations.
|
|
29
|
+
|
|
30
|
+
The SERVER (backend + dashboard + marketing + infra) is AGPL-3.0 — see the
|
|
31
|
+
`LICENSE` file at the repository root and `LICENSING.md` for the rationale.
|