yoru-cli 0.1.2__tar.gz → 0.1.3__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.1.3}/.gitignore +7 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/PKG-INFO +9 -10
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/README.md +6 -7
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/pyproject.toml +3 -3
- yoru_cli-0.1.3/src/yoru_cli/api.py +94 -0
- yoru_cli-0.1.3/src/yoru_cli/cli.py +137 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/hook_template.py +14 -15
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/init_cmd.py +17 -8
- yoru_cli-0.1.3/src/yoru_cli/share_cmd.py +188 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/transcript_tailer.py +81 -34
- yoru_cli-0.1.3/src/yoru_cli/update_cmd.py +250 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_cli.py +4 -1
- yoru_cli-0.1.3/tests/test_init.py +137 -0
- yoru_cli-0.1.3/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_init.py +0 -164
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/LICENSE +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/__init__.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/__main__.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/config.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/doctor_cmd.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/tail_cmd.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/__init__.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_doctor.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_hook_template.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_init_settings_merge.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_tail.py +0 -0
- {yoru_cli-0.1.2 → yoru_cli-0.1.3}/uv.lock +0 -0
|
@@ -70,3 +70,10 @@ 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
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yoru-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -35,19 +35,18 @@ The Claude Code hook installer for Yoru. One command installs a `PostToolUse` ho
|
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
pip install yoru-cli
|
|
38
|
-
yoru init
|
|
38
|
+
yoru init --server https://yoru.acme.com
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
`yoru init` opens a pairing URL in your browser, writes `~/.claude/hooks/yoru.sh`, and registers the hook in `~/.claude/settings.json`.
|
|
41
|
+
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
42
|
|
|
43
43
|
Requires Python 3.10+. One runtime dep: `httpx`.
|
|
44
44
|
|
|
45
45
|
## Usage
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
yoru init
|
|
49
|
-
yoru init --server https://yoru.acme
|
|
50
|
-
yoru init --token yoru_xxx --force # non-interactive (CI / scripted rotation)
|
|
48
|
+
yoru init --server https://yoru.acme.com # pair against your server (--server required)
|
|
49
|
+
yoru init --server https://yoru.acme.com --token yoru_xxx --force # non-interactive (CI / scripted rotation)
|
|
51
50
|
|
|
52
51
|
yoru tail < events.jsonl # post a batch of events (debug)
|
|
53
52
|
|
|
@@ -69,8 +68,8 @@ It uses `curl --max-time 2 || true` so a Yoru outage never stalls your terminal
|
|
|
69
68
|
|
|
70
69
|
## Self-host the server
|
|
71
70
|
|
|
72
|
-
The CLI is MIT. The server (backend + dashboard) is AGPL-3.0 and lives at **[github.com/
|
|
71
|
+
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
72
|
|
|
74
73
|
## License
|
|
75
74
|
|
|
76
|
-
[MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/
|
|
75
|
+
[MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/TsukumoHQ/cli-yoru](https://github.com/TsukumoHQ/cli-yoru).
|
|
@@ -9,19 +9,18 @@ The Claude Code hook installer for Yoru. One command installs a `PostToolUse` ho
|
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
pip install yoru-cli
|
|
12
|
-
yoru init
|
|
12
|
+
yoru init --server https://yoru.acme.com
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`yoru init` opens a pairing URL in your browser, writes `~/.claude/hooks/yoru.sh`, and registers the hook in `~/.claude/settings.json`.
|
|
15
|
+
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
16
|
|
|
17
17
|
Requires Python 3.10+. One runtime dep: `httpx`.
|
|
18
18
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
yoru init
|
|
23
|
-
yoru init --server https://yoru.acme
|
|
24
|
-
yoru init --token yoru_xxx --force # non-interactive (CI / scripted rotation)
|
|
22
|
+
yoru init --server https://yoru.acme.com # pair against your server (--server required)
|
|
23
|
+
yoru init --server https://yoru.acme.com --token yoru_xxx --force # non-interactive (CI / scripted rotation)
|
|
25
24
|
|
|
26
25
|
yoru tail < events.jsonl # post a batch of events (debug)
|
|
27
26
|
|
|
@@ -43,8 +42,8 @@ It uses `curl --max-time 2 || true` so a Yoru outage never stalls your terminal
|
|
|
43
42
|
|
|
44
43
|
## Self-host the server
|
|
45
44
|
|
|
46
|
-
The CLI is MIT. The server (backend + dashboard) is AGPL-3.0 and lives at **[github.com/
|
|
45
|
+
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
46
|
|
|
48
47
|
## License
|
|
49
48
|
|
|
50
|
-
[MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/
|
|
49
|
+
[MIT](./LICENSE) · Copyright (c) 2026 Yoru authors. Issues and PRs welcome at [github.com/TsukumoHQ/cli-yoru](https://github.com/TsukumoHQ/cli-yoru).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "yoru-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
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
|
|
@@ -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" \\
|
|
@@ -143,6 +143,22 @@ def _pair_device(server: str, label: str, *, no_browser: bool) -> str | None:
|
|
|
143
143
|
return None
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
def refresh_hook_assets() -> tuple[Path, Path]:
|
|
147
|
+
"""(Re)write the Claude Code hook script and (re)register it in
|
|
148
|
+
settings.json. Idempotent — used by `init` (first install) and `update`
|
|
149
|
+
(refresh the hook to the new version + repair the settings wiring). Returns
|
|
150
|
+
(hook_path, settings_path). Does NOT touch config (server/token)."""
|
|
151
|
+
hook_dir = Path.home() / ".claude" / "hooks"
|
|
152
|
+
hook_path = hook_dir / "yoru.sh"
|
|
153
|
+
os.makedirs(hook_dir, exist_ok=True)
|
|
154
|
+
hook_path.write_text(HOOK_SCRIPT, encoding="utf-8")
|
|
155
|
+
os.chmod(hook_path, 0o755)
|
|
156
|
+
|
|
157
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
158
|
+
_merge_settings_json(settings_path, hook_path)
|
|
159
|
+
return hook_path, settings_path
|
|
160
|
+
|
|
161
|
+
|
|
146
162
|
def run(args: argparse.Namespace) -> int:
|
|
147
163
|
if config.exists() and not getattr(args, "force", False):
|
|
148
164
|
print("Already installed (use --force to overwrite)", file=sys.stderr)
|
|
@@ -167,14 +183,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
167
183
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
168
184
|
})
|
|
169
185
|
|
|
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)
|
|
186
|
+
refresh_hook_assets()
|
|
178
187
|
|
|
179
188
|
print("\u2713 config \u2192 ~/.config/yoru/config.json")
|
|
180
189
|
print("\u2713 hook \u2192 ~/.claude/hooks/yoru.sh")
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""`yoru share` — public-share toggle for an agent session. Issue #80.
|
|
2
|
+
|
|
3
|
+
Two flows:
|
|
4
|
+
|
|
5
|
+
yoru share <session-id> # flip public, copy URL
|
|
6
|
+
yoru share --revoke <session-id> # flip back to private
|
|
7
|
+
|
|
8
|
+
Consent: the first-ever share on an account prompts for explicit
|
|
9
|
+
acknowledgment ("prompts and file paths will be visible"). The decision
|
|
10
|
+
is persisted server-side (users.share_consent_given_at) so it follows
|
|
11
|
+
the account across machines — a second machine running `yoru share`
|
|
12
|
+
won't re-ask. Previously stored in ~/.config/yoru/share-confirmed; the
|
|
13
|
+
local file is obsolete and is now ignored if it exists.
|
|
14
|
+
|
|
15
|
+
Clipboard: we try `pbcopy` on Darwin and `xclip -selection clipboard`
|
|
16
|
+
elsewhere; on failure we just print the URL loudly enough to copy from
|
|
17
|
+
terminal. No pyperclip dep on purpose — the std-lib `subprocess` path
|
|
18
|
+
works everywhere we ship.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import platform
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from . import config
|
|
31
|
+
from .api import ReceiptClient
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _prompt_consent() -> bool:
|
|
35
|
+
"""Interactive one-time acknowledgment. Non-interactive callers (CI,
|
|
36
|
+
piped stdin) can set YORU_SHARE_CONSENT=yes to skip."""
|
|
37
|
+
if os.environ.get("YORU_SHARE_CONSENT", "").strip().lower() in ("1", "yes", "true"):
|
|
38
|
+
return True
|
|
39
|
+
if not sys.stdin.isatty():
|
|
40
|
+
print(
|
|
41
|
+
"error: `yoru share` needs an interactive TTY the first time so "
|
|
42
|
+
"you can confirm the public-disclosure terms. Re-run from a "
|
|
43
|
+
"terminal, or set YORU_SHARE_CONSENT=yes if you're scripting.",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
print()
|
|
49
|
+
print(" Make this session publicly shareable?")
|
|
50
|
+
print()
|
|
51
|
+
print(" Visible publicly: prompts, tool calls, file paths, red flags, grade.")
|
|
52
|
+
print(" Always redacted: content of any event flagged secret_* (AWS / Stripe /")
|
|
53
|
+
print(" JWT / SSH / Anthropic key / Postgres URL / etc.).")
|
|
54
|
+
print()
|
|
55
|
+
print(" You'll only be asked once per account. Revoke any specific share at any")
|
|
56
|
+
print(" time with `yoru share --revoke <id>`.")
|
|
57
|
+
print()
|
|
58
|
+
try:
|
|
59
|
+
ans = input(" Share? [y/N]: ").strip().lower()
|
|
60
|
+
except EOFError:
|
|
61
|
+
return False
|
|
62
|
+
return ans in ("y", "yes")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _copy_to_clipboard(text: str) -> bool:
|
|
66
|
+
"""Best-effort clipboard copy. Returns True if a copy command succeeded."""
|
|
67
|
+
if platform.system() == "Darwin":
|
|
68
|
+
cmd = ["pbcopy"]
|
|
69
|
+
elif platform.system() == "Linux":
|
|
70
|
+
# xclip is the most common; wl-copy (Wayland) would be a worthy add.
|
|
71
|
+
cmd = ["xclip", "-selection", "clipboard"]
|
|
72
|
+
else:
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
subprocess.run(cmd, input=text.encode("utf-8"), check=True, timeout=2)
|
|
76
|
+
return True
|
|
77
|
+
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _format_tweet(url: str) -> str:
|
|
82
|
+
"""Pre-filled tweet text the user can paste after copying the URL.
|
|
83
|
+
|
|
84
|
+
Intentionally short — the *link* carries most of the payload; the
|
|
85
|
+
tweet needs just enough framing that a dev scrolling past knows what
|
|
86
|
+
they're looking at.
|
|
87
|
+
"""
|
|
88
|
+
return (
|
|
89
|
+
f"look what my Claude Code session just did — {url}\n\n"
|
|
90
|
+
"(yoru.sh — audit-grade trails for AI coding agents)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run(args: argparse.Namespace) -> int:
|
|
95
|
+
cfg = config.load()
|
|
96
|
+
if cfg is None:
|
|
97
|
+
print("error: not configured — run `yoru init` first.", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
server = (cfg.get("server") or "").rstrip("/")
|
|
100
|
+
token = cfg.get("token") or ""
|
|
101
|
+
if not server or not token:
|
|
102
|
+
print("error: config missing server or token — re-run `yoru init`.", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
session_id = (getattr(args, "session_id", None) or "").strip()
|
|
106
|
+
if not session_id:
|
|
107
|
+
print(
|
|
108
|
+
"error: missing <session-id>. Usage: yoru share <session-id> "
|
|
109
|
+
"[--revoke]",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
return 2
|
|
113
|
+
revoke = bool(getattr(args, "revoke", False))
|
|
114
|
+
|
|
115
|
+
client = ReceiptClient(server, token=token)
|
|
116
|
+
|
|
117
|
+
# Revoke path — no consent check, no clipboard. Fast and quiet.
|
|
118
|
+
if revoke:
|
|
119
|
+
try:
|
|
120
|
+
resp = client.revoke_share(session_id)
|
|
121
|
+
except httpx.HTTPStatusError as e:
|
|
122
|
+
if e.response.status_code == 404:
|
|
123
|
+
print(
|
|
124
|
+
f"error: session {session_id} not found (or not owned by this account).",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
return 3
|
|
128
|
+
print(f"error: revoke failed: {e}", file=sys.stderr)
|
|
129
|
+
return 4
|
|
130
|
+
except httpx.HTTPError as e:
|
|
131
|
+
print(f"error: revoke failed: {e}", file=sys.stderr)
|
|
132
|
+
return 4
|
|
133
|
+
print(f"✓ session {session_id} is private again.")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
# Share path — consent is server-side now (one stamp per account).
|
|
137
|
+
try:
|
|
138
|
+
consent = client.get_share_consent()
|
|
139
|
+
except httpx.HTTPError as e:
|
|
140
|
+
print(f"error: couldn't check share consent: {e}", file=sys.stderr)
|
|
141
|
+
return 4
|
|
142
|
+
|
|
143
|
+
if not consent.get("consented"):
|
|
144
|
+
ok = _prompt_consent()
|
|
145
|
+
if not ok:
|
|
146
|
+
print(" aborted — session stays private.")
|
|
147
|
+
return 0
|
|
148
|
+
try:
|
|
149
|
+
client.post_share_consent()
|
|
150
|
+
except httpx.HTTPError as e:
|
|
151
|
+
print(
|
|
152
|
+
f"error: couldn't save consent (not stamping server-side): {e}",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
return 4
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
resp = client.share_session(session_id)
|
|
159
|
+
except httpx.HTTPStatusError as e:
|
|
160
|
+
if e.response.status_code == 404:
|
|
161
|
+
print(
|
|
162
|
+
f"error: session {session_id} not found (or not owned by this account).",
|
|
163
|
+
file=sys.stderr,
|
|
164
|
+
)
|
|
165
|
+
return 3
|
|
166
|
+
print(f"error: share failed: {e}", file=sys.stderr)
|
|
167
|
+
return 4
|
|
168
|
+
except httpx.HTTPError as e:
|
|
169
|
+
print(f"error: share failed: {e}", file=sys.stderr)
|
|
170
|
+
return 4
|
|
171
|
+
|
|
172
|
+
url = resp.get("public_url") or ""
|
|
173
|
+
if not url:
|
|
174
|
+
print("error: backend returned no public_url — is the session actually public?", file=sys.stderr)
|
|
175
|
+
return 4
|
|
176
|
+
|
|
177
|
+
copied = _copy_to_clipboard(url)
|
|
178
|
+
|
|
179
|
+
print()
|
|
180
|
+
print(f" ✓ public {url}")
|
|
181
|
+
if copied:
|
|
182
|
+
print(" (copied to clipboard)")
|
|
183
|
+
print()
|
|
184
|
+
print(" suggested tweet:")
|
|
185
|
+
print(f" {_format_tweet(url)}")
|
|
186
|
+
print()
|
|
187
|
+
print(f" revoke: yoru share --revoke {session_id}")
|
|
188
|
+
return 0
|