project-notebook 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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,346 @@
1
+ """Project Notebook CLI.
2
+
3
+ Local interaction with the hub goes through these subcommands; the
4
+ subcommands that touch hub state are thin clients that call the hub's
5
+ HTTP API over loopback, so the hub remains the single source of truth.
6
+ """
7
+
8
+ import argparse
9
+ import asyncio
10
+ import importlib.resources as resources
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from urllib.parse import quote, urlparse, urlunparse
18
+
19
+ import aiohttp
20
+
21
+
22
+ def _state_dir() -> Path:
23
+ return Path(os.environ.get("PROJECT_NOTEBOOK_HOME", Path.home() / ".project-notebook"))
24
+
25
+
26
+ def _sock_path() -> Path:
27
+ return _state_dir() / "hub.sock"
28
+
29
+
30
+ # Local CLI talks to the hub over a Unix domain socket. With aiohttp's
31
+ # UnixConnector the transport IS the socket; the URL's host/port are ignored
32
+ # at the wire level (HTTP requires *some* host, so we use this placeholder).
33
+ # The aiohttp docs cover the connector but not URL construction — see:
34
+ # https://docs.aiohttp.org/en/stable/client_advanced.html
35
+ # https://github.com/aio-libs/aiohttp/issues/11324 (open issue asking for
36
+ # better docs / base_url support for exactly this case)
37
+ _HUB_URL_BASE = "http://localhost"
38
+
39
+
40
+ async def _request(method: str, path: str, payload: dict | None = None, timeout: float = 5.0):
41
+ conn = aiohttp.UnixConnector(path=str(_sock_path()))
42
+ async with aiohttp.ClientSession(
43
+ connector=conn, timeout=aiohttp.ClientTimeout(total=timeout)
44
+ ) as session:
45
+ async with session.request(method, _HUB_URL_BASE + path, json=payload) as r:
46
+ r.raise_for_status()
47
+ return await r.json()
48
+
49
+
50
+ def _get(path: str, timeout: float = 5.0):
51
+ return asyncio.run(_request("GET", path, timeout=timeout))
52
+
53
+
54
+ def _post(path: str, payload: dict, timeout: float = 5.0):
55
+ return asyncio.run(_request("POST", path, payload=payload, timeout=timeout))
56
+
57
+
58
+ def _hub_alive() -> bool:
59
+ try:
60
+ return _get("/api/health", timeout=0.5).get("service") == "project-notebook-hub"
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def _ensure_hub_running(timeout: float = 10.0):
66
+ """Reuse a running hub (health probe over the socket); otherwise spawn one detached."""
67
+ if _hub_alive():
68
+ return
69
+ state_dir = _state_dir()
70
+ state_dir.mkdir(parents=True, exist_ok=True)
71
+ log = open(state_dir / "hub.log", "a")
72
+ subprocess.Popen(
73
+ [sys.executable, "-m", "project_notebook.hub"],
74
+ stdout=log, stderr=log, start_new_session=True,
75
+ )
76
+ deadline = time.time() + timeout
77
+ while time.time() < deadline:
78
+ if _hub_alive():
79
+ return
80
+ time.sleep(0.2)
81
+ raise SystemExit(f"Hub did not become healthy within {timeout}s; see {state_dir / 'hub.log'}")
82
+
83
+
84
+ def cmd_hub(args):
85
+ from . import hub
86
+ hub.run()
87
+
88
+
89
+ def cmd_register(args):
90
+ _ensure_hub_running() # synchronous; must happen before entering an event loop
91
+ name = args.name or Path.cwd().name
92
+ path = str(Path.cwd())
93
+ asyncio.run(_stream_session(name, path))
94
+
95
+
96
+ async def _stream_session(name: str, path: str):
97
+ """Hold an SSE pipe to the hub: the project is registered while connected,
98
+ and each artifact prints one stdout line (a Monitor event). Reconnects with
99
+ backoff if the connection drops; runs until the process is killed."""
100
+ url = f"{_HUB_URL_BASE}/api/session?project={quote(name)}&path={quote(path)}"
101
+ while True:
102
+ try:
103
+ conn = aiohttp.UnixConnector(path=str(_sock_path()))
104
+ async with aiohttp.ClientSession(connector=conn) as session:
105
+ async with session.get(url) as resp:
106
+ print(f"Registered '{name}' — watching for artifacts", file=sys.stderr, flush=True)
107
+ async for raw in resp.content:
108
+ line = raw.decode(errors="replace").strip()
109
+ if not line.startswith("data:"):
110
+ continue
111
+ try:
112
+ event = json.loads(line[len("data:"):].strip())
113
+ except json.JSONDecodeError:
114
+ continue
115
+ etype = event.get("type")
116
+ if etype == "artifact_received":
117
+ print(f"New artifact: {event.get('filename', '?')} ({event.get('path', '')})", flush=True)
118
+ elif etype == "artifact_processed":
119
+ proc = event.get("processor", "?")
120
+ outs = event.get("outputs") or []
121
+ err = event.get("error")
122
+ name = event.get("filename", "?")
123
+ sidecar = event.get("sidecar_dir", "")
124
+ if outs:
125
+ print(f"Processed: {name} via {proc} -> {', '.join(outs)} (in {sidecar})", flush=True)
126
+ elif err:
127
+ print(f"Processed: {name} via {proc} skipped: {err}", flush=True)
128
+ except (aiohttp.ClientError, OSError):
129
+ pass # connection dropped — reconnect with backoff
130
+ await asyncio.sleep(2)
131
+
132
+
133
+ def cmd_status(args):
134
+ if not _hub_alive():
135
+ print("Hub is not running.")
136
+ return
137
+ projects = _get("/api/projects")["projects"]
138
+ if not projects:
139
+ print("Hub running. No active sessions.")
140
+ return
141
+ print("Hub running. Active sessions:")
142
+ for p in projects:
143
+ print(f" {p['name']} ({p['path']}) artifacts: {len(p['artifacts'])}")
144
+
145
+
146
+ def cmd_install_claude_code_skill(args):
147
+ config_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
148
+ dest = config_dir / "skills" / "notebook-register"
149
+ dest.mkdir(parents=True, exist_ok=True)
150
+ src = resources.files("project_notebook") / "skill"
151
+ count = 0
152
+ for item in src.iterdir():
153
+ (dest / item.name).write_bytes(item.read_bytes())
154
+ count += 1
155
+ print(f"Installed {count} skill file(s) to {dest}")
156
+
157
+
158
+ def cmd_pair(args):
159
+ from . import pairing
160
+ _ensure_hub_running()
161
+ resp = _post("/api/pair/new", {})
162
+ if args.address:
163
+ # Override: encode just the given host (keeping the hub's chosen port).
164
+ u = urlparse(resp["lan_url"])
165
+ netloc = f"{args.address}:{u.port}" if u.port else args.address
166
+ urls = [urlunparse(u._replace(netloc=netloc))]
167
+ else:
168
+ urls = resp.get("lan_urls") or [resp["lan_url"]]
169
+ # Encode every candidate so the phone can try each — repeated ?url= keys are
170
+ # legal per RFC 3986 and parsed as a list by URLComponents on iOS.
171
+ query = "&".join(f"url={quote(u, safe='')}" for u in urls) + f"&code={quote(resp['code'])}"
172
+ deep_link = f"projectnotebook://pair?{query}"
173
+ print(pairing.render_qr(deep_link))
174
+ print("Hub address(es) encoded (phone will try each):")
175
+ for u in urls:
176
+ print(f" {u}")
177
+ print(f"Link: {deep_link}")
178
+ print(f"Scan within {resp['ttl']}s.")
179
+
180
+
181
+ def cmd_check(args):
182
+ """Report which features are available based on installed external tools.
183
+
184
+ Passive: never installs anything, never starts the hub. Each feature lists
185
+ its tools in priority order; the highest-priority installed one is shown
186
+ as active. When a feature is off, the install hint is printed for every
187
+ compatible tool so the user can choose. `--json` emits the same view as
188
+ structured data for tooling.
189
+ """
190
+ from . import tools as t
191
+
192
+ if args.json:
193
+ out = {
194
+ "platform": t.detect_platform(),
195
+ "features": [
196
+ {
197
+ "id": f.id,
198
+ "name": f.name,
199
+ "on": f.is_on(),
200
+ "active_tool": (f.resolve().id if f.resolve() else None),
201
+ "tools": [
202
+ {
203
+ "id": tool.id,
204
+ "supported_here": tool.supported_here(),
205
+ "installed": tool.installed(),
206
+ "priority": tool.priority,
207
+ "install_hint": tool.install_command(),
208
+ "notes": tool.notes,
209
+ }
210
+ for tool in f.tools
211
+ ],
212
+ }
213
+ for f in t.FEATURES
214
+ ],
215
+ }
216
+ print(json.dumps(out, indent=2))
217
+ if not all(f.is_on() for f in t.FEATURES):
218
+ raise SystemExit(1)
219
+ return
220
+
221
+ print(f"Project Notebook · features ({t.detect_platform()})")
222
+ print()
223
+ any_off = False
224
+ for f in t.FEATURES:
225
+ active = f.resolve()
226
+ recommended = f.recommended()
227
+ on = active is not None
228
+ if not on:
229
+ any_off = True
230
+ mark = "✓" if on else "⚠"
231
+ print(f"{mark} {f.name}")
232
+ for tool in f.tools:
233
+ if not tool.supported_here():
234
+ continue
235
+ if tool.installed():
236
+ tag = " ← active" if (active and tool.id == active.id) else ""
237
+ print(f" {tool.id:18s} installed{tag}")
238
+ else:
239
+ tag = " ← recommended" if (recommended and tool.id == recommended.id) else ""
240
+ print(f" {tool.id:18s} not installed{tag}")
241
+ if not on:
242
+ cmd = tool.install_command()
243
+ if cmd:
244
+ print(f" {'':18s} {cmd}")
245
+ if not on:
246
+ print(f" ⚠ feature off")
247
+ print()
248
+ if any_off:
249
+ print("Some features are off. (Install the recommended tool above; `project-notebook setup` coming soon.)")
250
+ raise SystemExit(1)
251
+ print("All features available.")
252
+
253
+
254
+ def cmd_annotate(args):
255
+ """Merge a JSON annotations payload into an artifact's meta.yaml.
256
+
257
+ This is the *session's* hook: extraction processors fill in mechanical
258
+ fields (codec, duration, transcript); the session — with the conversation
259
+ context — fills in `annotations` (what this artifact *means* in the
260
+ context of what we're working on). Writing here so we don't ask the model
261
+ to round-trip YAML correctly.
262
+ """
263
+ import yaml
264
+ from datetime import datetime, timezone
265
+
266
+ target = Path(args.path).expanduser()
267
+ if not target.exists():
268
+ raise SystemExit(f"No such file or directory: {target}")
269
+ sidecar_dir = target if target.is_dir() else target.parent
270
+
271
+ raw = sys.stdin.read() if args.json in (None, "-") else args.json
272
+ try:
273
+ payload = json.loads(raw)
274
+ except json.JSONDecodeError as e:
275
+ raise SystemExit(f"Invalid JSON: {e}")
276
+ if not isinstance(payload, dict):
277
+ raise SystemExit("Annotations payload must be a JSON object")
278
+
279
+ meta_path = sidecar_dir / "meta.yaml"
280
+ meta = yaml.safe_load(meta_path.read_text()) if meta_path.exists() else {}
281
+ if not isinstance(meta, dict):
282
+ meta = {}
283
+
284
+ payload["annotated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
285
+ meta["annotations"] = payload
286
+ meta_path.write_text(yaml.safe_dump(meta, sort_keys=False, allow_unicode=True))
287
+ print(f"Wrote annotations to {meta_path}")
288
+
289
+
290
+ def cmd_devices(args):
291
+ _ensure_hub_running()
292
+ if args.revoke:
293
+ resp = _post("/api/devices/revoke", {"device_id": args.revoke})
294
+ print(f"Revoked '{resp['name']}' ({resp['device_id']})")
295
+ return
296
+ devs = _get("/api/devices")["devices"]
297
+ if not devs:
298
+ print("No paired devices.")
299
+ return
300
+ print("Paired devices:")
301
+ for d in devs:
302
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["paired_at"]))
303
+ print(f" {d['id']} {d['name']} (paired {when})")
304
+
305
+
306
+ def main(argv=None):
307
+ parser = argparse.ArgumentParser(prog="project-notebook", description="Project Notebook hub + CLI")
308
+ sub = parser.add_subparsers(dest="command", required=True)
309
+
310
+ p = sub.add_parser("hub", help="Run the hub server (foreground)")
311
+ p.set_defaults(func=cmd_hub)
312
+
313
+ p = sub.add_parser("register", help="Open a session pipe: register this project and stream its artifacts (run via Monitor)")
314
+ p.add_argument("name", nargs="?", help="Project name (default: current directory name)")
315
+ p.set_defaults(func=cmd_register)
316
+
317
+ p = sub.add_parser("status", help="Show the hub and active sessions")
318
+ p.set_defaults(func=cmd_status)
319
+
320
+ p = sub.add_parser("check", help="Report which features are available based on installed external tools")
321
+ p.add_argument("--json", action="store_true", help="Emit JSON for tooling")
322
+ p.set_defaults(func=cmd_check)
323
+
324
+ p = sub.add_parser("install-claude-code-skill", help="Install the Claude Code skill into ~/.claude/skills")
325
+ p.set_defaults(func=cmd_install_claude_code_skill)
326
+
327
+ p = sub.add_parser("pair", help="Pair a phone by printing a QR code to scan")
328
+ p.add_argument("--address", metavar="HOST", help="Override the hub host in the QR (e.g. your Tailscale IP, when mDNS/LAN is blocked)")
329
+ p.set_defaults(func=cmd_pair)
330
+
331
+ p = sub.add_parser("annotate", help="Merge a JSON annotations payload into an artifact's meta.yaml (reads JSON from stdin)")
332
+ p.add_argument("path", help="Artifact file or its sidecar directory")
333
+ p.add_argument("--json", default="-", help="Inline JSON payload, or '-' to read from stdin (default)")
334
+ p.set_defaults(func=cmd_annotate)
335
+
336
+ p = sub.add_parser("devices", help="List or revoke paired devices")
337
+ p.add_argument("--revoke", metavar="ID", help="Revoke the device with this id")
338
+ p.set_defaults(func=cmd_devices)
339
+
340
+ args = parser.parse_args(argv)
341
+ try:
342
+ args.func(args)
343
+ except aiohttp.ClientResponseError as e:
344
+ raise SystemExit(f"Hub returned {e.status}: {e.message}")
345
+ except aiohttp.ClientError as e:
346
+ raise SystemExit(f"Could not reach hub over {_sock_path()}: {e}")