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