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.
- project_notebook/__init__.py +1 -0
- project_notebook/cli.py +346 -0
- project_notebook/hub.py +593 -0
- project_notebook/pairing.py +94 -0
- project_notebook/processors/__init__.py +71 -0
- project_notebook/processors/extract.py +108 -0
- project_notebook/processors/transcribe.py +107 -0
- project_notebook/skill/SKILL.md +105 -0
- project_notebook/tools.py +152 -0
- project_notebook-0.1.0.dist-info/METADATA +120 -0
- project_notebook-0.1.0.dist-info/RECORD +14 -0
- project_notebook-0.1.0.dist-info/WHEEL +4 -0
- project_notebook-0.1.0.dist-info/entry_points.txt +3 -0
- project_notebook-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
project_notebook/cli.py
ADDED
|
@@ -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}")
|