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.
Files changed (29) hide show
  1. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/.gitignore +7 -0
  2. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/PKG-INFO +9 -10
  3. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/README.md +6 -7
  4. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/pyproject.toml +3 -3
  5. yoru_cli-0.1.3/src/yoru_cli/api.py +94 -0
  6. yoru_cli-0.1.3/src/yoru_cli/cli.py +137 -0
  7. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/hook_template.py +14 -15
  8. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/init_cmd.py +17 -8
  9. yoru_cli-0.1.3/src/yoru_cli/share_cmd.py +188 -0
  10. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/transcript_tailer.py +81 -34
  11. yoru_cli-0.1.3/src/yoru_cli/update_cmd.py +250 -0
  12. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_cli.py +4 -1
  13. yoru_cli-0.1.3/tests/test_init.py +137 -0
  14. yoru_cli-0.1.3/tests/test_update.py +187 -0
  15. yoru_cli-0.1.2/src/yoru_cli/api.py +0 -40
  16. yoru_cli-0.1.2/src/yoru_cli/cli.py +0 -84
  17. yoru_cli-0.1.2/tests/test_init.py +0 -164
  18. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/LICENSE +0 -0
  19. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/__init__.py +0 -0
  20. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/__main__.py +0 -0
  21. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/config.py +0 -0
  22. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/doctor_cmd.py +0 -0
  23. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/src/yoru_cli/tail_cmd.py +0 -0
  24. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/__init__.py +0 -0
  25. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_doctor.py +0 -0
  26. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_hook_template.py +0 -0
  27. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_init_settings_merge.py +0 -0
  28. {yoru_cli-0.1.2 → yoru_cli-0.1.3}/tests/test_tail.py +0 -0
  29. {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.2
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/yoru-sh/cli
8
- Project-URL: Issues, https://github.com/yoru-sh/cli/issues
7
+ Project-URL: Repository, https://github.com/TsukumoHQ/cli-yoru
8
+ Project-URL: Issues, https://github.com/TsukumoHQ/cli-yoru/issues
9
9
  Author-email: Yoru authors <hello@yoru.sh>
10
10
  License: MIT
11
11
  License-File: LICENSE
@@ -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`. By default it points at Yoru Cloud ([yoru.sh](https://yoru.sh)) — free forever for one developer.
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 # pair against https://api.yoru.sh
49
- yoru init --server https://yoru.acme # pair against your self-hosted server
50
- yoru init --token yoru_xxx --force # non-interactive (CI / scripted rotation)
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/yoru-sh/yoru](https://github.com/yoru-sh/yoru)**. `docker-compose up`, then `yoru init --server https://your-host` — done.
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/yoru-sh/cli](https://github.com/yoru-sh/cli).
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`. By default it points at Yoru Cloud ([yoru.sh](https://yoru.sh)) — free forever for one developer.
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 # pair against https://api.yoru.sh
23
- yoru init --server https://yoru.acme # pair against your self-hosted server
24
- yoru init --token yoru_xxx --force # non-interactive (CI / scripted rotation)
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/yoru-sh/yoru](https://github.com/yoru-sh/yoru)**. `docker-compose up`, then `yoru init --server https://your-host` — done.
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/yoru-sh/cli](https://github.com/yoru-sh/cli).
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.2"
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/yoru-sh/cli"
27
- Issues = "https://github.com/yoru-sh/cli/issues"
26
+ Repository = "https://github.com/TsukumoHQ/cli-yoru"
27
+ Issues = "https://github.com/TsukumoHQ/cli-yoru/issues"
28
28
 
29
29
  [project.scripts]
30
30
  yoru = "yoru_cli.cli:main"
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+
8
+ class ReceiptClient:
9
+ def __init__(self, base_url: str, token: str | None = None) -> None:
10
+ self.base_url = base_url.rstrip("/")
11
+ self.token = token
12
+
13
+ def start_device_code(self, label: str | None = None) -> dict[str, Any]:
14
+ """Begin the device-pairing handshake — no auth needed."""
15
+ r = httpx.post(
16
+ f"{self.base_url}/api/v1/auth/device-code",
17
+ json={"label": label} if label else {},
18
+ timeout=5.0,
19
+ )
20
+ r.raise_for_status()
21
+ return r.json()
22
+
23
+ def poll_device_code(self, device_code: str) -> dict[str, Any]:
24
+ """Poll for approval — returns {status, token?}."""
25
+ r = httpx.post(
26
+ f"{self.base_url}/api/v1/auth/device-code/poll",
27
+ json={"device_code": device_code},
28
+ timeout=10.0,
29
+ )
30
+ r.raise_for_status()
31
+ return r.json()
32
+
33
+ def post_events(self, events: list[dict[str, Any]]) -> httpx.Response:
34
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
35
+ return httpx.post(
36
+ f"{self.base_url}/api/v1/sessions/events",
37
+ json={"events": events},
38
+ headers=headers,
39
+ timeout=5.0,
40
+ )
41
+
42
+ def share_session(self, session_id: str) -> dict[str, Any]:
43
+ """Flip a session public (#79). Requires bearer token — 401 otherwise.
44
+
45
+ Backend is idempotent: re-POST on an already-public session returns
46
+ the same `public_url`. 404 on cross-user (token's user doesn't own
47
+ this session) — callers should treat that as "not your session".
48
+ """
49
+ if not self.token:
50
+ raise RuntimeError("share_session requires authentication (run `yoru init`)")
51
+ r = httpx.post(
52
+ f"{self.base_url}/api/v1/sessions/{session_id}/share",
53
+ json={"source": "cli"},
54
+ headers={"Authorization": f"Bearer {self.token}"},
55
+ timeout=10.0,
56
+ )
57
+ r.raise_for_status()
58
+ return r.json()
59
+
60
+ def revoke_share(self, session_id: str) -> dict[str, Any]:
61
+ """Flip a session back to private (#79). Idempotent."""
62
+ if not self.token:
63
+ raise RuntimeError("revoke_share requires authentication (run `yoru init`)")
64
+ r = httpx.post(
65
+ f"{self.base_url}/api/v1/sessions/{session_id}/share/revoke",
66
+ headers={"Authorization": f"Bearer {self.token}"},
67
+ timeout=10.0,
68
+ )
69
+ r.raise_for_status()
70
+ return r.json()
71
+
72
+ def get_share_consent(self) -> dict[str, Any]:
73
+ """Returns {consented: bool, at: str|None} for the authenticated user."""
74
+ if not self.token:
75
+ raise RuntimeError("get_share_consent requires authentication")
76
+ r = httpx.get(
77
+ f"{self.base_url}/api/v1/account/share-consent",
78
+ headers={"Authorization": f"Bearer {self.token}"},
79
+ timeout=5.0,
80
+ )
81
+ r.raise_for_status()
82
+ return r.json()
83
+
84
+ def post_share_consent(self) -> dict[str, Any]:
85
+ """Stamp share consent for the authenticated user (idempotent)."""
86
+ if not self.token:
87
+ raise RuntimeError("post_share_consent requires authentication")
88
+ r = httpx.post(
89
+ f"{self.base_url}/api/v1/account/share-consent",
90
+ headers={"Authorization": f"Bearer {self.token}"},
91
+ timeout=5.0,
92
+ )
93
+ r.raise_for_status()
94
+ return r.json()
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from . import __version__
6
+ from . import config, doctor_cmd, init_cmd, share_cmd, tail_cmd, update_cmd
7
+
8
+
9
+ def _build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(
11
+ prog="yoru",
12
+ description="Yoru — audit-grade session receipts for autonomous AI coding agents.",
13
+ )
14
+ parser.add_argument(
15
+ "--version",
16
+ action="version",
17
+ version=f"yoru {__version__}",
18
+ )
19
+
20
+ subparsers = parser.add_subparsers(
21
+ dest="cmd", required=True, metavar="{init,tail,doctor,share,update}"
22
+ )
23
+
24
+ p_init = subparsers.add_parser(
25
+ "init",
26
+ help="Install the Claude Code hook and write ~/.config/yoru/config.json.",
27
+ )
28
+ p_init.add_argument(
29
+ "--server",
30
+ required=True,
31
+ help="Backend URL of your Yoru server, e.g. https://yoru.acme.com. "
32
+ "Yoru is self-hosted — there is no default server.",
33
+ )
34
+ p_init.add_argument(
35
+ "--token",
36
+ default=None,
37
+ help="Pre-minted hook token (rcpt_...) — for headless/CI/server setups. "
38
+ "Also read from $YORU_TOKEN. Without this, yoru init launches "
39
+ "interactive device pairing.",
40
+ )
41
+ p_init.add_argument(
42
+ "--label",
43
+ default=None,
44
+ help="Human-readable machine label shown in the dashboard "
45
+ "(default: <hostname> · <os>).",
46
+ )
47
+ p_init.add_argument(
48
+ "--no-browser",
49
+ action="store_true",
50
+ help="Don't try to auto-open the pairing URL in a browser.",
51
+ )
52
+ p_init.add_argument("--force", action="store_true", help="Overwrite an existing install.")
53
+
54
+ p_tail = subparsers.add_parser(
55
+ "tail",
56
+ help="Read JSON events from stdin and POST them as a batch (dev/debug).",
57
+ )
58
+ p_tail.add_argument(
59
+ "--server",
60
+ default=None,
61
+ help="Backend URL (default: the server saved in config by `yoru init`).",
62
+ )
63
+ p_tail.add_argument("--session-id", default=None, help="Session id to stamp on events missing one.")
64
+
65
+ subparsers.add_parser(
66
+ "doctor",
67
+ help="Diagnose the install: config, backend, token, hook.",
68
+ )
69
+
70
+ p_share = subparsers.add_parser(
71
+ "share",
72
+ help="Flip a session public and copy its yoru.sh/s/<id> URL to the clipboard.",
73
+ )
74
+ p_share.add_argument(
75
+ "session_id",
76
+ help="Session id to share (or revoke). Find it in the dashboard URL or the session header.",
77
+ )
78
+ p_share.add_argument(
79
+ "--revoke",
80
+ action="store_true",
81
+ help="Flip the session back to private instead of sharing it.",
82
+ )
83
+
84
+ p_update = subparsers.add_parser(
85
+ "update",
86
+ help="Self-update the CLI to the latest release (pip), and refresh the hook.",
87
+ )
88
+ p_update.add_argument(
89
+ "--force",
90
+ action="store_true",
91
+ help="Bypass the dev-build and no-downgrade guards (explicit reinstall/downgrade).",
92
+ )
93
+ p_update.add_argument(
94
+ "--check",
95
+ action="store_true",
96
+ help="Report whether an update is available and exit — install nothing.",
97
+ )
98
+ p_update.add_argument(
99
+ "--server",
100
+ nargs="?",
101
+ const="",
102
+ default=None,
103
+ metavar="URL",
104
+ help="Check the running SERVER's version (notify-only) instead of self-updating "
105
+ "the CLI. Bare --server uses your configured server; --server URL targets that "
106
+ "instance. Never pulls or restarts the server.",
107
+ )
108
+
109
+ return parser
110
+
111
+
112
+ def main(argv: list[str] | None = None) -> int:
113
+ parser = _build_parser()
114
+ args = parser.parse_args(argv)
115
+
116
+ if args.cmd == "tail" and args.server is None:
117
+ cfg = config.load() or {}
118
+ args.server = cfg.get("server")
119
+ if not args.server:
120
+ parser.error(
121
+ "no server configured — run `yoru init --server <url>` first, "
122
+ "or pass --server explicitly."
123
+ )
124
+
125
+ if args.cmd == "init":
126
+ return init_cmd.run(args)
127
+ if args.cmd == "tail":
128
+ return tail_cmd.run(args)
129
+ if args.cmd == "doctor":
130
+ return doctor_cmd.run(args)
131
+ if args.cmd == "share":
132
+ return share_cmd.run(args)
133
+ if args.cmd == "update":
134
+ return update_cmd.run(args)
135
+
136
+ parser.error(f"unknown command: {args.cmd!r}")
137
+ return 2
@@ -81,24 +81,23 @@ if git_branch: e["git_branch"] = git_branch
81
81
  hen=e.get("hook_event_name")
82
82
  if hen=="SessionStart":
83
83
  e["kind"]="session_start"
84
- elif hen=="UserPromptSubmit":
85
- e["kind"]="message"
86
- e["tool"]="user"
87
- p=e.get("prompt")
88
- if isinstance(p,str) and p: e["content"]=p[:2000]
89
- elif hen=="Notification":
90
- e["kind"]="message"
91
- e["tool"]="notification"
92
- m=e.get("message")
93
- if isinstance(m,str) and m: e["content"]=m[:2000]
94
- elif hen=="SubagentStop":
95
- e["kind"]="message"
96
- e["tool"]="subagent"
97
- e["content"]="subagent stopped"
98
84
  elif hen=="Stop":
99
85
  e["kind"]="session_end"
100
- # PostToolUse / PreToolUse: leave kind unset → backend _infer_kind() from tool
86
+ # Discreet share CTA after session_end (surfaced via hook stderr).
87
+ # (avoid apostrophes here - this block is inside a bash $(python3 -c '...'))
88
+ sid_stop = original.get("session_id") or original.get("sessionId") or ""
89
+ if isinstance(sid_stop, str) and sid_stop:
90
+ try:
91
+ sys.stderr.write(f"\\n[yoru] session ended - make it public: yoru share {sid_stop}\\n")
92
+ except Exception:
93
+ pass
94
+ else:
95
+ # Tool calls, prompts, notifications now come from the durable transcript
96
+ # tailer (yoru_cli.transcript_tailer): single source, no duplicates, and
97
+ # they survive backend downtime. The hook only ships session lifecycle.
98
+ sys.exit(0)
101
99
  print(json.dumps({"events":[e]}))')
100
+ [ -z "${BODY}" ] && exit 0
102
101
  curl -sS --max-time 2 -X POST "${SERVER}/api/v1/sessions/events" \\
103
102
  -H "Authorization: Bearer ${TOKEN}" \\
104
103
  -H "Content-Type: application/json" \\
@@ -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
- 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)
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