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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
yoru_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yoru = yoru_cli.cli:main
@@ -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.