promty-collector 0.1.0
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.
- package/bin/promty.js +17 -0
- package/package.json +18 -0
- package/src/adapters/__init__.py +32 -0
- package/src/adapters/claude/__init__.py +1 -0
- package/src/adapters/claude/hook.py +11 -0
- package/src/adapters/codex/__init__.py +1 -0
- package/src/adapters/codex/hook.py +11 -0
- package/src/adapters/common.py +193 -0
- package/src/adapters/cursor/__init__.py +1 -0
- package/src/adapters/cursor/hook.py +11 -0
- package/src/adapters/gemini/__init__.py +1 -0
- package/src/adapters/gemini/hook.py +11 -0
- package/src/change_tracking.py +696 -0
- package/src/cli.py +1110 -0
- package/src/config.py +90 -0
- package/src/events.py +313 -0
- package/src/file_lock.py +38 -0
- package/src/git_context.py +69 -0
- package/src/payloads.py +58 -0
- package/src/response_capture.py +199 -0
- package/src/runtime_install.py +162 -0
- package/src/sequence.py +68 -0
- package/src/session_index.py +75 -0
- package/src/uploader/__init__.py +1 -0
- package/src/uploader/client.py +35 -0
- package/src/uploader/queue.py +130 -0
package/src/cli.py
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
from urllib import error, parse, request
|
|
15
|
+
import webbrowser
|
|
16
|
+
|
|
17
|
+
from adapters import normalize_collector_event
|
|
18
|
+
from change_tracking import ChangeBaselineStore, detect_changes
|
|
19
|
+
from config import (
|
|
20
|
+
DEFAULT_UPLOADER_LOG_PATH,
|
|
21
|
+
DEFAULT_UPLOADER_PID_PATH,
|
|
22
|
+
read_config,
|
|
23
|
+
resolve_api_url,
|
|
24
|
+
resolve_app_url,
|
|
25
|
+
resolve_token,
|
|
26
|
+
write_config,
|
|
27
|
+
)
|
|
28
|
+
from events import (
|
|
29
|
+
SUPPORTED_EVENT_TYPES,
|
|
30
|
+
TOOL_ALIASES,
|
|
31
|
+
BaseEvent,
|
|
32
|
+
FilesChangedPayload,
|
|
33
|
+
SupportedTool,
|
|
34
|
+
normalize_event_type,
|
|
35
|
+
normalize_tool,
|
|
36
|
+
)
|
|
37
|
+
from payloads import (
|
|
38
|
+
PROJECT_CONTEXT_KEYS,
|
|
39
|
+
SESSION_ID_KEYS,
|
|
40
|
+
WORKSPACE_KEYS,
|
|
41
|
+
get_first_string,
|
|
42
|
+
)
|
|
43
|
+
from runtime_install import install_runtime, quote_command_path
|
|
44
|
+
from sequence import SequenceStore
|
|
45
|
+
from session_index import SessionIndex
|
|
46
|
+
from uploader.client import PromptHubUploader
|
|
47
|
+
from uploader.queue import JSONLQueue
|
|
48
|
+
|
|
49
|
+
InstallTarget = Literal["all", "claude-code", "codex-cli"]
|
|
50
|
+
INSTALL_TOOLS: tuple[SupportedTool, ...] = ("codex-cli", "claude-code")
|
|
51
|
+
INSTALL_TOOL_ALIASES: dict[str, InstallTarget] = {
|
|
52
|
+
"all": "all",
|
|
53
|
+
"claude": "claude-code",
|
|
54
|
+
"claude-code": "claude-code",
|
|
55
|
+
"codex": "codex-cli",
|
|
56
|
+
"codex-cli": "codex-cli",
|
|
57
|
+
}
|
|
58
|
+
CODEX_HOOKS: tuple[dict[str, Any], ...] = (
|
|
59
|
+
{
|
|
60
|
+
"event": "UserPromptSubmit",
|
|
61
|
+
"subcommand": "capture",
|
|
62
|
+
"timeout": 5,
|
|
63
|
+
"statusMessage": "Capturing Promty event",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"event": "Stop",
|
|
67
|
+
"subcommand": "capture-changes",
|
|
68
|
+
"timeout": 10,
|
|
69
|
+
"statusMessage": "Capturing Promty AI activity",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
CLAUDE_HOOKS: tuple[dict[str, Any], ...] = (
|
|
73
|
+
{
|
|
74
|
+
"event": "SessionStart",
|
|
75
|
+
"subcommand": "capture --event-type SessionStarted",
|
|
76
|
+
"timeout": 5,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"event": "UserPromptSubmit",
|
|
80
|
+
"subcommand": "capture",
|
|
81
|
+
"timeout": 5,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"event": "Stop",
|
|
85
|
+
"subcommand": "capture-changes",
|
|
86
|
+
"timeout": 10,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"event": "SessionEnd",
|
|
90
|
+
"subcommand": "capture --event-type SessionEnded",
|
|
91
|
+
"timeout": 5,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
LOGIN_CALLBACK_HTML = """<!doctype html>
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="utf-8" />
|
|
98
|
+
<title>Promty connected</title>
|
|
99
|
+
<style>
|
|
100
|
+
body {
|
|
101
|
+
margin: 0;
|
|
102
|
+
background: #09090b;
|
|
103
|
+
color: #fafafa;
|
|
104
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
105
|
+
display: grid;
|
|
106
|
+
min-height: 100vh;
|
|
107
|
+
place-items: center;
|
|
108
|
+
}
|
|
109
|
+
main {
|
|
110
|
+
width: min(440px, calc(100vw - 40px));
|
|
111
|
+
border: 1px solid #2a2f38;
|
|
112
|
+
border-radius: 8px;
|
|
113
|
+
padding: 24px;
|
|
114
|
+
background: #111113;
|
|
115
|
+
}
|
|
116
|
+
h1 {
|
|
117
|
+
margin: 0 0 8px;
|
|
118
|
+
font-size: 22px;
|
|
119
|
+
}
|
|
120
|
+
p {
|
|
121
|
+
margin: 0;
|
|
122
|
+
color: #c4cad4;
|
|
123
|
+
line-height: 1.5;
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<main>
|
|
129
|
+
<h1>Promty connected</h1>
|
|
130
|
+
<p>You can close this window and return to your terminal.</p>
|
|
131
|
+
</main>
|
|
132
|
+
</body>
|
|
133
|
+
</html>
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read_stdin_json() -> dict[str, Any]:
|
|
138
|
+
raw = sys.stdin.read()
|
|
139
|
+
if not raw.strip():
|
|
140
|
+
raise ValueError("Expected hook JSON on stdin")
|
|
141
|
+
|
|
142
|
+
payload = json.loads(raw)
|
|
143
|
+
if not isinstance(payload, dict):
|
|
144
|
+
raise ValueError("Expected hook JSON object on stdin")
|
|
145
|
+
return payload
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def capture_raw(args: argparse.Namespace) -> int:
|
|
149
|
+
payload = _read_stdin_json()
|
|
150
|
+
output_path = Path(args.output).expanduser()
|
|
151
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
with output_path.open("w", encoding="utf-8") as file:
|
|
153
|
+
json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
154
|
+
file.write("\n")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _has_project_context(payload: dict[str, Any]) -> bool:
|
|
159
|
+
if os.environ.get("PROMPTHUB_PROJECT_ID"):
|
|
160
|
+
return True
|
|
161
|
+
return get_first_string(payload, PROJECT_CONTEXT_KEYS) is not None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _normalize_required_tool(args: argparse.Namespace) -> SupportedTool:
|
|
165
|
+
tool = args.source or args.tool
|
|
166
|
+
if not tool:
|
|
167
|
+
raise ValueError("Expected --tool")
|
|
168
|
+
return normalize_tool(tool)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _normalize_install_target(args: argparse.Namespace) -> InstallTarget:
|
|
172
|
+
tool = getattr(args, "source", None) or getattr(args, "tool", None)
|
|
173
|
+
if not tool:
|
|
174
|
+
raise ValueError("Expected --tool")
|
|
175
|
+
try:
|
|
176
|
+
return INSTALL_TOOL_ALIASES[tool]
|
|
177
|
+
except KeyError as exc:
|
|
178
|
+
raise ValueError(f"Unsupported hook integration: {tool}") from exc
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _tools_for_install_target(target: InstallTarget) -> tuple[SupportedTool, ...]:
|
|
182
|
+
if target == "all":
|
|
183
|
+
return INSTALL_TOOLS
|
|
184
|
+
return (target,)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _apply_known_session(
|
|
188
|
+
*,
|
|
189
|
+
index: SessionIndex,
|
|
190
|
+
tool: SupportedTool,
|
|
191
|
+
external_session_id: str | None,
|
|
192
|
+
event: BaseEvent,
|
|
193
|
+
raw_payload: dict[str, Any],
|
|
194
|
+
) -> None:
|
|
195
|
+
if external_session_id is None or _has_project_context(raw_payload):
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
record = index.lookup(tool, external_session_id)
|
|
199
|
+
if not record:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
project_id = record.get("project_id")
|
|
203
|
+
session_id = record.get("session_id")
|
|
204
|
+
if isinstance(project_id, str) and isinstance(session_id, str):
|
|
205
|
+
event.project_id = project_id
|
|
206
|
+
event.session_id = session_id
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _remember_session(
|
|
210
|
+
*,
|
|
211
|
+
index: SessionIndex,
|
|
212
|
+
tool: SupportedTool,
|
|
213
|
+
external_session_id: str | None,
|
|
214
|
+
event: BaseEvent,
|
|
215
|
+
raw_payload: dict[str, Any],
|
|
216
|
+
) -> None:
|
|
217
|
+
if external_session_id is None:
|
|
218
|
+
return
|
|
219
|
+
index.observe(
|
|
220
|
+
tool=tool,
|
|
221
|
+
external_session_id=external_session_id,
|
|
222
|
+
event=event,
|
|
223
|
+
cwd=get_first_string(raw_payload, WORKSPACE_KEYS),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def capture(args: argparse.Namespace) -> int:
|
|
228
|
+
payload = _read_stdin_json()
|
|
229
|
+
normalized_tool = _normalize_required_tool(args)
|
|
230
|
+
event_type = normalize_event_type(args.event_type) if args.event_type else None
|
|
231
|
+
event = normalize_collector_event(normalized_tool, payload, event_type)
|
|
232
|
+
session_index = SessionIndex(args.session_index_path)
|
|
233
|
+
external_session_id = get_first_string(payload, SESSION_ID_KEYS)
|
|
234
|
+
_apply_known_session(
|
|
235
|
+
index=session_index,
|
|
236
|
+
tool=normalized_tool,
|
|
237
|
+
external_session_id=external_session_id,
|
|
238
|
+
event=event,
|
|
239
|
+
raw_payload=payload,
|
|
240
|
+
)
|
|
241
|
+
SequenceStore(args.sequence_path).assign(event)
|
|
242
|
+
JSONLQueue(args.queue_path).push(event)
|
|
243
|
+
_remember_session(
|
|
244
|
+
index=session_index,
|
|
245
|
+
tool=normalized_tool,
|
|
246
|
+
external_session_id=external_session_id,
|
|
247
|
+
event=event,
|
|
248
|
+
raw_payload=payload,
|
|
249
|
+
)
|
|
250
|
+
if event.event_type == "PromptSubmitted":
|
|
251
|
+
ChangeBaselineStore(args.change_baseline_path).observe_prompt(
|
|
252
|
+
tool=normalized_tool,
|
|
253
|
+
event=event,
|
|
254
|
+
raw_payload=payload,
|
|
255
|
+
external_session_id=external_session_id,
|
|
256
|
+
cwd=get_first_string(payload, WORKSPACE_KEYS) or os.getcwd(),
|
|
257
|
+
)
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def capture_changes(args: argparse.Namespace) -> int:
|
|
262
|
+
payload = _read_stdin_json()
|
|
263
|
+
normalized_tool = _normalize_required_tool(args)
|
|
264
|
+
external_session_id = get_first_string(payload, SESSION_ID_KEYS)
|
|
265
|
+
cwd = get_first_string(payload, WORKSPACE_KEYS) or os.getcwd()
|
|
266
|
+
session_index = SessionIndex(args.session_index_path)
|
|
267
|
+
sequence_store = SequenceStore(args.sequence_path)
|
|
268
|
+
queue = JSONLQueue(args.queue_path)
|
|
269
|
+
|
|
270
|
+
response_event = normalize_collector_event(
|
|
271
|
+
normalized_tool,
|
|
272
|
+
payload,
|
|
273
|
+
"ResponseReceived",
|
|
274
|
+
)
|
|
275
|
+
_apply_known_session(
|
|
276
|
+
index=session_index,
|
|
277
|
+
tool=normalized_tool,
|
|
278
|
+
external_session_id=external_session_id,
|
|
279
|
+
event=response_event,
|
|
280
|
+
raw_payload=payload,
|
|
281
|
+
)
|
|
282
|
+
sequence_store.assign(response_event)
|
|
283
|
+
queue.push(response_event)
|
|
284
|
+
_remember_session(
|
|
285
|
+
index=session_index,
|
|
286
|
+
tool=normalized_tool,
|
|
287
|
+
external_session_id=external_session_id,
|
|
288
|
+
event=response_event,
|
|
289
|
+
raw_payload=payload,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
store = ChangeBaselineStore(args.change_baseline_path)
|
|
293
|
+
baseline = store.find_latest(
|
|
294
|
+
tool=normalized_tool,
|
|
295
|
+
external_session_id=external_session_id,
|
|
296
|
+
cwd=cwd,
|
|
297
|
+
)
|
|
298
|
+
if not baseline:
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
result = detect_changes(baseline, cwd)
|
|
302
|
+
store.mark_consumed(str(baseline["id"]))
|
|
303
|
+
if result is None:
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
event = BaseEvent(
|
|
307
|
+
tool=normalized_tool,
|
|
308
|
+
event_type="FilesChanged",
|
|
309
|
+
payload=FilesChangedPayload(**result.payload),
|
|
310
|
+
project_id=str(baseline["project_id"]),
|
|
311
|
+
session_id=str(baseline["session_id"]),
|
|
312
|
+
sequence=0,
|
|
313
|
+
)
|
|
314
|
+
sequence_store.assign(event)
|
|
315
|
+
queue.push(event)
|
|
316
|
+
return 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _upload_queued_events(args: argparse.Namespace) -> int:
|
|
320
|
+
queue = JSONLQueue(args.queue_path)
|
|
321
|
+
events = queue.read_batch(args.limit)
|
|
322
|
+
if not events:
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
uploader = PromptHubUploader(
|
|
326
|
+
api_url=resolve_api_url(args.api_url, args.config_path),
|
|
327
|
+
token=resolve_token(args.token, args.config_path),
|
|
328
|
+
timeout=args.timeout,
|
|
329
|
+
)
|
|
330
|
+
uploaded_ids = uploader.upload_events(events)
|
|
331
|
+
queue.ack(set(uploaded_ids))
|
|
332
|
+
return len(uploaded_ids)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def upload(args: argparse.Namespace) -> int:
|
|
336
|
+
api_url = resolve_api_url(args.api_url, args.config_path)
|
|
337
|
+
if not args.watch:
|
|
338
|
+
uploaded_count = _upload_queued_events(args)
|
|
339
|
+
if uploaded_count:
|
|
340
|
+
print(f"Uploaded {uploaded_count} events")
|
|
341
|
+
else:
|
|
342
|
+
print("No events queued")
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
interval = max(args.interval, 0.25)
|
|
346
|
+
print(
|
|
347
|
+
"Watching queue "
|
|
348
|
+
f"{JSONLQueue(args.queue_path).path} -> {api_url} "
|
|
349
|
+
f"every {interval:g}s"
|
|
350
|
+
)
|
|
351
|
+
while True:
|
|
352
|
+
try:
|
|
353
|
+
uploaded_count = _upload_queued_events(args)
|
|
354
|
+
if uploaded_count:
|
|
355
|
+
print(f"Uploaded {uploaded_count} events", flush=True)
|
|
356
|
+
except KeyboardInterrupt:
|
|
357
|
+
print("Stopped uploader watch")
|
|
358
|
+
return 0
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
print(f"Upload failed: {exc}", file=sys.stderr, flush=True)
|
|
361
|
+
time.sleep(interval)
|
|
362
|
+
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _LoginCallbackHandler(BaseHTTPRequestHandler):
|
|
367
|
+
server: "_LoginCallbackServer"
|
|
368
|
+
|
|
369
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
def do_GET(self) -> None:
|
|
373
|
+
parsed = parse.urlparse(self.path)
|
|
374
|
+
query = parse.parse_qs(parsed.query)
|
|
375
|
+
state = query.get("state", [""])[0]
|
|
376
|
+
token = query.get("token", query.get("access_token", [""]))[0]
|
|
377
|
+
api_url = query.get("api_url", [""])[0]
|
|
378
|
+
username = query.get("username", [""])[0]
|
|
379
|
+
error_message = query.get("error", [""])[0]
|
|
380
|
+
|
|
381
|
+
if parsed.path != "/callback":
|
|
382
|
+
self.send_error(404)
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if not secrets.compare_digest(state, self.server.expected_state):
|
|
386
|
+
self.server.error_message = "Login callback state did not match"
|
|
387
|
+
self.send_error(400)
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
if error_message:
|
|
391
|
+
self.server.error_message = error_message
|
|
392
|
+
self.send_error(400)
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if not token:
|
|
396
|
+
self.server.error_message = "Login callback did not include a token"
|
|
397
|
+
self.send_error(400)
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
self.server.result = {
|
|
401
|
+
"token": token,
|
|
402
|
+
"api_url": api_url or self.server.default_api_url,
|
|
403
|
+
"username": username or None,
|
|
404
|
+
}
|
|
405
|
+
body = LOGIN_CALLBACK_HTML.encode("utf-8")
|
|
406
|
+
self.send_response(200)
|
|
407
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
408
|
+
self.send_header("Content-Length", str(len(body)))
|
|
409
|
+
self.end_headers()
|
|
410
|
+
self.wfile.write(body)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class _LoginCallbackServer(HTTPServer):
|
|
414
|
+
expected_state: str
|
|
415
|
+
default_api_url: str
|
|
416
|
+
result: dict[str, Any] | None
|
|
417
|
+
error_message: str | None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _build_login_url(
|
|
421
|
+
*,
|
|
422
|
+
app_url: str,
|
|
423
|
+
api_url: str,
|
|
424
|
+
callback_url: str,
|
|
425
|
+
state: str,
|
|
426
|
+
) -> str:
|
|
427
|
+
query = parse.urlencode(
|
|
428
|
+
{
|
|
429
|
+
"source": "collector",
|
|
430
|
+
"provider": "github",
|
|
431
|
+
"state": state,
|
|
432
|
+
"redirect_uri": callback_url,
|
|
433
|
+
"api_url": api_url,
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
return f"{app_url.rstrip('/')}/cli/login?{query}"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _wait_for_login_callback(
|
|
440
|
+
*,
|
|
441
|
+
app_url: str,
|
|
442
|
+
api_url: str,
|
|
443
|
+
callback_port: int,
|
|
444
|
+
timeout: float,
|
|
445
|
+
open_browser: bool,
|
|
446
|
+
) -> dict[str, Any]:
|
|
447
|
+
state = secrets.token_urlsafe(32)
|
|
448
|
+
with _LoginCallbackServer(
|
|
449
|
+
("127.0.0.1", callback_port),
|
|
450
|
+
_LoginCallbackHandler,
|
|
451
|
+
) as server:
|
|
452
|
+
server.expected_state = state
|
|
453
|
+
server.default_api_url = api_url
|
|
454
|
+
server.result = None
|
|
455
|
+
server.error_message = None
|
|
456
|
+
server.timeout = 0.5
|
|
457
|
+
actual_port = server.server_address[1]
|
|
458
|
+
callback_url = f"http://127.0.0.1:{actual_port}/callback"
|
|
459
|
+
login_url = _build_login_url(
|
|
460
|
+
app_url=app_url,
|
|
461
|
+
api_url=api_url,
|
|
462
|
+
callback_url=callback_url,
|
|
463
|
+
state=state,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
print(f"Open Promty login: {login_url}", flush=True)
|
|
467
|
+
if open_browser:
|
|
468
|
+
webbrowser.open(login_url)
|
|
469
|
+
|
|
470
|
+
deadline = time.monotonic() + timeout
|
|
471
|
+
while time.monotonic() < deadline and server.result is None:
|
|
472
|
+
server.handle_request()
|
|
473
|
+
if server.error_message:
|
|
474
|
+
raise RuntimeError(server.error_message)
|
|
475
|
+
|
|
476
|
+
if server.result is None:
|
|
477
|
+
raise TimeoutError("Timed out waiting for Promty login callback")
|
|
478
|
+
return server.result
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def login(args: argparse.Namespace) -> int:
|
|
482
|
+
app_url = resolve_app_url(args.app_url, args.config_path)
|
|
483
|
+
api_url = resolve_api_url(args.api_url, args.config_path)
|
|
484
|
+
if args.token:
|
|
485
|
+
auth = {
|
|
486
|
+
"provider": "github",
|
|
487
|
+
"username": args.username,
|
|
488
|
+
"logged_in_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
489
|
+
}
|
|
490
|
+
write_config(
|
|
491
|
+
{
|
|
492
|
+
"app_url": app_url,
|
|
493
|
+
"api_url": api_url,
|
|
494
|
+
"token": args.token,
|
|
495
|
+
"auth": auth,
|
|
496
|
+
},
|
|
497
|
+
args.config_path,
|
|
498
|
+
)
|
|
499
|
+
print(f"Promty login saved for {api_url}")
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
callback = _wait_for_login_callback(
|
|
503
|
+
app_url=app_url,
|
|
504
|
+
api_url=api_url,
|
|
505
|
+
callback_port=args.callback_port,
|
|
506
|
+
timeout=args.timeout,
|
|
507
|
+
open_browser=not args.no_browser,
|
|
508
|
+
)
|
|
509
|
+
username = callback.get("username")
|
|
510
|
+
auth = {
|
|
511
|
+
"provider": "github",
|
|
512
|
+
"username": username,
|
|
513
|
+
"logged_in_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
514
|
+
}
|
|
515
|
+
write_config(
|
|
516
|
+
{
|
|
517
|
+
"app_url": app_url,
|
|
518
|
+
"api_url": callback.get("api_url") or api_url,
|
|
519
|
+
"token": callback["token"],
|
|
520
|
+
"auth": auth,
|
|
521
|
+
},
|
|
522
|
+
args.config_path,
|
|
523
|
+
)
|
|
524
|
+
suffix = f" as {username}" if username else ""
|
|
525
|
+
print(f"Promty login saved{suffix}")
|
|
526
|
+
return 0
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _git_root(path: str | Path | None = None) -> Path:
|
|
530
|
+
start = Path(path or os.getcwd()).expanduser()
|
|
531
|
+
if not start.exists():
|
|
532
|
+
raise FileNotFoundError(f"Path does not exist: {start}")
|
|
533
|
+
result = subprocess.run(
|
|
534
|
+
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
|
|
535
|
+
check=False,
|
|
536
|
+
capture_output=True,
|
|
537
|
+
text=True,
|
|
538
|
+
)
|
|
539
|
+
if result.returncode != 0:
|
|
540
|
+
raise RuntimeError(f"Not inside a git repository: {start}")
|
|
541
|
+
return Path(result.stdout.strip()).resolve()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _read_hooks_config(path: Path) -> dict[str, Any]:
|
|
545
|
+
if not path.exists():
|
|
546
|
+
return {"hooks": {}}
|
|
547
|
+
|
|
548
|
+
with path.open("r", encoding="utf-8") as file:
|
|
549
|
+
payload = json.load(file)
|
|
550
|
+
if not isinstance(payload, dict):
|
|
551
|
+
raise ValueError(f"Expected JSON object in {path}")
|
|
552
|
+
hooks = payload.setdefault("hooks", {})
|
|
553
|
+
if not isinstance(hooks, dict):
|
|
554
|
+
raise ValueError(f"Expected hooks object in {path}")
|
|
555
|
+
return payload
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _is_promty_hook(hook: dict[str, Any], subcommand: str) -> bool:
|
|
559
|
+
command = str(hook.get("command", ""))
|
|
560
|
+
status_message = str(hook.get("statusMessage", ""))
|
|
561
|
+
return (
|
|
562
|
+
hook.get("type") == "command"
|
|
563
|
+
and subcommand in command
|
|
564
|
+
and (
|
|
565
|
+
"promty" in command.lower()
|
|
566
|
+
or "prompthub" in command.lower()
|
|
567
|
+
or "collector/src/cli.py" in command
|
|
568
|
+
or "promty" in status_message.lower()
|
|
569
|
+
or "prompthub" in status_message.lower()
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _upsert_command_hook(
|
|
575
|
+
config: dict[str, Any],
|
|
576
|
+
*,
|
|
577
|
+
event: str,
|
|
578
|
+
hook: dict[str, Any],
|
|
579
|
+
subcommand: str,
|
|
580
|
+
) -> str:
|
|
581
|
+
hooks_by_event = config.setdefault("hooks", {})
|
|
582
|
+
groups = hooks_by_event.setdefault(event, [])
|
|
583
|
+
if not isinstance(groups, list):
|
|
584
|
+
raise ValueError(f"Expected hooks.{event} to be a list")
|
|
585
|
+
|
|
586
|
+
for group in groups:
|
|
587
|
+
if not isinstance(group, dict):
|
|
588
|
+
continue
|
|
589
|
+
group_hooks = group.get("hooks")
|
|
590
|
+
if not isinstance(group_hooks, list):
|
|
591
|
+
continue
|
|
592
|
+
for existing in group_hooks:
|
|
593
|
+
if not isinstance(existing, dict):
|
|
594
|
+
continue
|
|
595
|
+
if existing.get("command") == hook["command"] or _is_promty_hook(
|
|
596
|
+
existing,
|
|
597
|
+
subcommand,
|
|
598
|
+
):
|
|
599
|
+
existing.update(hook)
|
|
600
|
+
return "updated"
|
|
601
|
+
|
|
602
|
+
groups.append({"hooks": [hook]})
|
|
603
|
+
return "installed"
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _write_hooks_config(path: Path, config: dict[str, Any]) -> None:
|
|
607
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
with path.open("w", encoding="utf-8") as file:
|
|
609
|
+
json.dump(config, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
610
|
+
file.write("\n")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _install_codex_hooks(
|
|
614
|
+
*,
|
|
615
|
+
command_prefix: str,
|
|
616
|
+
normalized_tool: SupportedTool,
|
|
617
|
+
repo_root: Path,
|
|
618
|
+
) -> int:
|
|
619
|
+
hooks_path = repo_root / ".codex" / "hooks.json"
|
|
620
|
+
config = _read_hooks_config(hooks_path)
|
|
621
|
+
results: list[str] = []
|
|
622
|
+
|
|
623
|
+
for spec in CODEX_HOOKS:
|
|
624
|
+
command = f"{command_prefix} {spec['subcommand']} --tool {normalized_tool}"
|
|
625
|
+
hook = {
|
|
626
|
+
"type": "command",
|
|
627
|
+
"command": command,
|
|
628
|
+
"timeout": spec["timeout"],
|
|
629
|
+
"statusMessage": spec["statusMessage"],
|
|
630
|
+
}
|
|
631
|
+
result = _upsert_command_hook(
|
|
632
|
+
config,
|
|
633
|
+
event=spec["event"],
|
|
634
|
+
hook=hook,
|
|
635
|
+
subcommand=spec["subcommand"],
|
|
636
|
+
)
|
|
637
|
+
results.append(f"{spec['event']}: {result}")
|
|
638
|
+
|
|
639
|
+
_write_hooks_config(hooks_path, config)
|
|
640
|
+
print(f"Codex hooks ready: {hooks_path}")
|
|
641
|
+
for result in results:
|
|
642
|
+
print(f"- {result}")
|
|
643
|
+
print("Open Codex /hooks and trust this repository's Promty hooks.")
|
|
644
|
+
return 0
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _install_claude_hooks(
|
|
648
|
+
*,
|
|
649
|
+
command_prefix: str,
|
|
650
|
+
normalized_tool: SupportedTool,
|
|
651
|
+
repo_root: Path,
|
|
652
|
+
) -> int:
|
|
653
|
+
settings_path = repo_root / ".claude" / "settings.local.json"
|
|
654
|
+
config = _read_hooks_config(settings_path)
|
|
655
|
+
results: list[str] = []
|
|
656
|
+
|
|
657
|
+
for spec in CLAUDE_HOOKS:
|
|
658
|
+
command = f"{command_prefix} {spec['subcommand']} --tool {normalized_tool}"
|
|
659
|
+
hook = {
|
|
660
|
+
"type": "command",
|
|
661
|
+
"command": command,
|
|
662
|
+
"timeout": spec["timeout"],
|
|
663
|
+
}
|
|
664
|
+
result = _upsert_command_hook(
|
|
665
|
+
config,
|
|
666
|
+
event=spec["event"],
|
|
667
|
+
hook=hook,
|
|
668
|
+
subcommand=spec["subcommand"],
|
|
669
|
+
)
|
|
670
|
+
results.append(f"{spec['event']}: {result}")
|
|
671
|
+
|
|
672
|
+
_write_hooks_config(settings_path, config)
|
|
673
|
+
print(f"Claude hooks ready: {settings_path}")
|
|
674
|
+
for result in results:
|
|
675
|
+
print(f"- {result}")
|
|
676
|
+
print("Claude Code will run these Promty hooks from this repository.")
|
|
677
|
+
return 0
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def install_hooks(args: argparse.Namespace) -> int:
|
|
681
|
+
target = _normalize_install_target(args)
|
|
682
|
+
repo_root = _git_root(args.repo_root)
|
|
683
|
+
if args.hook_command:
|
|
684
|
+
command_prefix = args.hook_command
|
|
685
|
+
else:
|
|
686
|
+
launcher_path = install_runtime()
|
|
687
|
+
command_prefix = quote_command_path(launcher_path)
|
|
688
|
+
print(f"Promty runtime ready: {launcher_path}")
|
|
689
|
+
|
|
690
|
+
failures: list[tuple[SupportedTool, Exception]] = []
|
|
691
|
+
for normalized_tool in _tools_for_install_target(target):
|
|
692
|
+
try:
|
|
693
|
+
if normalized_tool == "codex-cli":
|
|
694
|
+
_install_codex_hooks(
|
|
695
|
+
command_prefix=command_prefix,
|
|
696
|
+
normalized_tool=normalized_tool,
|
|
697
|
+
repo_root=repo_root,
|
|
698
|
+
)
|
|
699
|
+
elif normalized_tool == "claude-code":
|
|
700
|
+
_install_claude_hooks(
|
|
701
|
+
command_prefix=command_prefix,
|
|
702
|
+
normalized_tool=normalized_tool,
|
|
703
|
+
repo_root=repo_root,
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
raise AssertionError(f"Unexpected hook tool: {normalized_tool}")
|
|
707
|
+
except Exception as exc:
|
|
708
|
+
failures.append((normalized_tool, exc))
|
|
709
|
+
print(f"{normalized_tool} hook installation failed: {exc}", file=sys.stderr)
|
|
710
|
+
|
|
711
|
+
if failures:
|
|
712
|
+
print("Promty hook installation incomplete", file=sys.stderr)
|
|
713
|
+
return 1
|
|
714
|
+
return 0
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _pid_is_running(pid: int) -> bool:
|
|
718
|
+
try:
|
|
719
|
+
os.kill(pid, 0)
|
|
720
|
+
except OSError:
|
|
721
|
+
return False
|
|
722
|
+
return True
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _read_pid(path: Path) -> int | None:
|
|
726
|
+
try:
|
|
727
|
+
return int(path.read_text(encoding="utf-8").strip())
|
|
728
|
+
except (OSError, ValueError):
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def start_uploader(args: argparse.Namespace) -> int:
|
|
733
|
+
api_url = resolve_api_url(args.api_url, args.config_path)
|
|
734
|
+
token = resolve_token(args.token, args.config_path)
|
|
735
|
+
pid_path = Path(args.pid_path).expanduser() if args.pid_path else DEFAULT_UPLOADER_PID_PATH
|
|
736
|
+
log_path = Path(args.log_path).expanduser() if args.log_path else DEFAULT_UPLOADER_LOG_PATH
|
|
737
|
+
existing_pid = _read_pid(pid_path)
|
|
738
|
+
if existing_pid is not None and _pid_is_running(existing_pid):
|
|
739
|
+
print(f"Promty uploader already running: pid {existing_pid}")
|
|
740
|
+
return 0
|
|
741
|
+
|
|
742
|
+
env = os.environ.copy()
|
|
743
|
+
env["PROMPTHUB_API_URL"] = api_url
|
|
744
|
+
if token:
|
|
745
|
+
env["PROMPTHUB_API_TOKEN"] = token
|
|
746
|
+
if args.config_path:
|
|
747
|
+
env["PROMPTHUB_CONFIG_PATH"] = str(Path(args.config_path).expanduser())
|
|
748
|
+
|
|
749
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
750
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
751
|
+
log_file = log_path.open("a", encoding="utf-8")
|
|
752
|
+
command = [
|
|
753
|
+
sys.executable,
|
|
754
|
+
str(Path(__file__).resolve()),
|
|
755
|
+
"upload",
|
|
756
|
+
"--watch",
|
|
757
|
+
"--interval",
|
|
758
|
+
str(max(args.interval, 0.25)),
|
|
759
|
+
]
|
|
760
|
+
process = subprocess.Popen(
|
|
761
|
+
command,
|
|
762
|
+
stdout=log_file,
|
|
763
|
+
stderr=log_file,
|
|
764
|
+
stdin=subprocess.DEVNULL,
|
|
765
|
+
start_new_session=True,
|
|
766
|
+
env=env,
|
|
767
|
+
)
|
|
768
|
+
log_file.close()
|
|
769
|
+
pid_path.write_text(f"{process.pid}\n", encoding="utf-8")
|
|
770
|
+
print(f"Promty uploader started: pid {process.pid}")
|
|
771
|
+
print(f"Uploader log: {log_path}")
|
|
772
|
+
return 0
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _health_status(api_url: str, timeout: float) -> tuple[bool, str]:
|
|
776
|
+
try:
|
|
777
|
+
with request.urlopen(f"{api_url.rstrip('/')}/health", timeout=timeout) as response:
|
|
778
|
+
if response.status == 200:
|
|
779
|
+
return True, "ok"
|
|
780
|
+
return False, f"HTTP {response.status}"
|
|
781
|
+
except error.URLError as exc:
|
|
782
|
+
return False, str(exc.reason)
|
|
783
|
+
except OSError as exc:
|
|
784
|
+
return False, str(exc)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _hook_status(repo_root: Path, tool: SupportedTool) -> tuple[bool, str]:
|
|
788
|
+
if tool == "codex-cli":
|
|
789
|
+
hooks_path = repo_root / ".codex" / "hooks.json"
|
|
790
|
+
specs = CODEX_HOOKS
|
|
791
|
+
success = "installed; Codex trust must be confirmed in /hooks"
|
|
792
|
+
elif tool == "claude-code":
|
|
793
|
+
hooks_path = repo_root / ".claude" / "settings.local.json"
|
|
794
|
+
specs = CLAUDE_HOOKS
|
|
795
|
+
success = "installed in .claude/settings.local.json"
|
|
796
|
+
else:
|
|
797
|
+
return False, f"{tool} hooks are not supported"
|
|
798
|
+
|
|
799
|
+
if not hooks_path.exists():
|
|
800
|
+
return False, f"missing {hooks_path.relative_to(repo_root)}"
|
|
801
|
+
try:
|
|
802
|
+
config = _read_hooks_config(hooks_path)
|
|
803
|
+
except Exception as exc:
|
|
804
|
+
return False, str(exc)
|
|
805
|
+
|
|
806
|
+
hooks_by_event = config.get("hooks", {})
|
|
807
|
+
for spec in specs:
|
|
808
|
+
groups = hooks_by_event.get(spec["event"], [])
|
|
809
|
+
found = False
|
|
810
|
+
if isinstance(groups, list):
|
|
811
|
+
for group in groups:
|
|
812
|
+
if not isinstance(group, dict):
|
|
813
|
+
continue
|
|
814
|
+
for hook in group.get("hooks", []):
|
|
815
|
+
if isinstance(hook, dict) and _is_promty_hook(
|
|
816
|
+
hook,
|
|
817
|
+
spec["subcommand"],
|
|
818
|
+
):
|
|
819
|
+
found = True
|
|
820
|
+
break
|
|
821
|
+
if found:
|
|
822
|
+
break
|
|
823
|
+
if not found:
|
|
824
|
+
return False, f"missing {spec['event']} {tool} hook"
|
|
825
|
+
return True, success
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _queue_status(queue_path: str | None) -> tuple[bool, str]:
|
|
829
|
+
queue = JSONLQueue(queue_path)
|
|
830
|
+
if queue.root is None:
|
|
831
|
+
if queue.path.exists():
|
|
832
|
+
return True, str(queue.path)
|
|
833
|
+
return True, f"{queue.path} (empty)"
|
|
834
|
+
queue_files = sorted(queue.path.glob("*/*/events.jsonl")) if queue.path.exists() else []
|
|
835
|
+
return True, f"{len(queue_files)} session queue files under {queue.path}"
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def doctor(args: argparse.Namespace) -> int:
|
|
839
|
+
api_url = resolve_api_url(args.api_url, args.config_path)
|
|
840
|
+
token = resolve_token(args.token, args.config_path)
|
|
841
|
+
config = read_config(args.config_path)
|
|
842
|
+
target = _normalize_install_target(args)
|
|
843
|
+
target_tools = _tools_for_install_target(target)
|
|
844
|
+
try:
|
|
845
|
+
repo_root = _git_root(args.repo_root)
|
|
846
|
+
hook_results = [
|
|
847
|
+
(tool, _hook_status(repo_root, tool))
|
|
848
|
+
for tool in target_tools
|
|
849
|
+
]
|
|
850
|
+
except Exception as exc:
|
|
851
|
+
hook_results = [
|
|
852
|
+
(tool, (False, f"git root error: {exc}"))
|
|
853
|
+
for tool in target_tools
|
|
854
|
+
]
|
|
855
|
+
|
|
856
|
+
checks: list[tuple[str, bool, str]] = []
|
|
857
|
+
checks.append(("config", bool(config), str(args.config_path or "~/.prompthub/config.json")))
|
|
858
|
+
checks.append(("login", token is not None, "token saved" if token else "not logged in"))
|
|
859
|
+
for tool, hooks_status in hook_results:
|
|
860
|
+
check_name = "hooks" if target != "all" else f"hooks/{tool}"
|
|
861
|
+
checks.append((check_name, *hooks_status))
|
|
862
|
+
checks.append(("queue", *_queue_status(args.queue_path)))
|
|
863
|
+
checks.append(("backend", *_health_status(api_url, args.timeout)))
|
|
864
|
+
pid_path = Path(args.pid_path).expanduser() if args.pid_path else DEFAULT_UPLOADER_PID_PATH
|
|
865
|
+
pid = _read_pid(pid_path)
|
|
866
|
+
checks.append(
|
|
867
|
+
(
|
|
868
|
+
"uploader",
|
|
869
|
+
pid is not None and _pid_is_running(pid),
|
|
870
|
+
f"pid {pid}" if pid is not None and _pid_is_running(pid) else "not running",
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
failed = False
|
|
875
|
+
for name, ok, message in checks:
|
|
876
|
+
status = "ok" if ok else "needs-action"
|
|
877
|
+
print(f"{name}: {status} - {message}")
|
|
878
|
+
failed = failed or not ok
|
|
879
|
+
return 1 if failed else 0
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def init(args: argparse.Namespace) -> int:
|
|
883
|
+
if not args.skip_login and not resolve_token(args.token, args.config_path):
|
|
884
|
+
login_args = argparse.Namespace(
|
|
885
|
+
app_url=args.app_url,
|
|
886
|
+
api_url=args.api_url,
|
|
887
|
+
callback_port=args.callback_port,
|
|
888
|
+
config_path=args.config_path,
|
|
889
|
+
no_browser=args.no_browser,
|
|
890
|
+
timeout=args.login_timeout,
|
|
891
|
+
token=None,
|
|
892
|
+
username=None,
|
|
893
|
+
)
|
|
894
|
+
login(login_args)
|
|
895
|
+
elif args.token:
|
|
896
|
+
login_args = argparse.Namespace(
|
|
897
|
+
app_url=args.app_url,
|
|
898
|
+
api_url=args.api_url,
|
|
899
|
+
callback_port=args.callback_port,
|
|
900
|
+
config_path=args.config_path,
|
|
901
|
+
no_browser=True,
|
|
902
|
+
timeout=args.login_timeout,
|
|
903
|
+
token=args.token,
|
|
904
|
+
username=args.username,
|
|
905
|
+
)
|
|
906
|
+
login(login_args)
|
|
907
|
+
|
|
908
|
+
install_args = argparse.Namespace(
|
|
909
|
+
tool=args.tool,
|
|
910
|
+
source=None,
|
|
911
|
+
repo_root=args.repo_root,
|
|
912
|
+
hook_command=args.hook_command,
|
|
913
|
+
)
|
|
914
|
+
hooks_result = install_hooks(install_args)
|
|
915
|
+
|
|
916
|
+
if not args.skip_uploader:
|
|
917
|
+
uploader_args = argparse.Namespace(
|
|
918
|
+
api_url=args.api_url,
|
|
919
|
+
config_path=args.config_path,
|
|
920
|
+
interval=args.upload_interval,
|
|
921
|
+
log_path=args.log_path,
|
|
922
|
+
pid_path=args.pid_path,
|
|
923
|
+
token=args.token,
|
|
924
|
+
)
|
|
925
|
+
start_uploader(uploader_args)
|
|
926
|
+
|
|
927
|
+
if hooks_result != 0:
|
|
928
|
+
print(
|
|
929
|
+
"Promty init incomplete; fix the hook error and run this command again.",
|
|
930
|
+
file=sys.stderr,
|
|
931
|
+
)
|
|
932
|
+
return 1
|
|
933
|
+
|
|
934
|
+
print("Promty init complete")
|
|
935
|
+
return 0
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
939
|
+
parser = argparse.ArgumentParser(
|
|
940
|
+
prog="promty",
|
|
941
|
+
description="Promty local AI activity collector",
|
|
942
|
+
)
|
|
943
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
944
|
+
|
|
945
|
+
capture_parser = subparsers.add_parser("capture")
|
|
946
|
+
capture_parser.add_argument(
|
|
947
|
+
"--tool",
|
|
948
|
+
choices=sorted(TOOL_ALIASES),
|
|
949
|
+
help="AI tool that produced the hook payload",
|
|
950
|
+
)
|
|
951
|
+
capture_parser.add_argument(
|
|
952
|
+
"--source",
|
|
953
|
+
choices=("claude", "codex"),
|
|
954
|
+
help="Deprecated alias for --tool",
|
|
955
|
+
)
|
|
956
|
+
capture_parser.add_argument(
|
|
957
|
+
"--event-type",
|
|
958
|
+
choices=SUPPORTED_EVENT_TYPES,
|
|
959
|
+
help="Promty event type. Defaults to payload event_type or PromptSubmitted.",
|
|
960
|
+
)
|
|
961
|
+
capture_parser.add_argument("--queue-path")
|
|
962
|
+
capture_parser.add_argument("--sequence-path")
|
|
963
|
+
capture_parser.add_argument("--session-index-path")
|
|
964
|
+
capture_parser.add_argument("--change-baseline-path")
|
|
965
|
+
capture_parser.set_defaults(func=capture)
|
|
966
|
+
|
|
967
|
+
capture_changes_parser = subparsers.add_parser("capture-changes")
|
|
968
|
+
capture_changes_parser.add_argument(
|
|
969
|
+
"--tool",
|
|
970
|
+
choices=sorted(TOOL_ALIASES),
|
|
971
|
+
help="AI tool that produced the stop hook payload",
|
|
972
|
+
)
|
|
973
|
+
capture_changes_parser.add_argument(
|
|
974
|
+
"--source",
|
|
975
|
+
choices=("claude", "codex"),
|
|
976
|
+
help="Deprecated alias for --tool",
|
|
977
|
+
)
|
|
978
|
+
capture_changes_parser.add_argument("--queue-path")
|
|
979
|
+
capture_changes_parser.add_argument("--sequence-path")
|
|
980
|
+
capture_changes_parser.add_argument("--session-index-path")
|
|
981
|
+
capture_changes_parser.add_argument("--change-baseline-path")
|
|
982
|
+
capture_changes_parser.set_defaults(func=capture_changes)
|
|
983
|
+
|
|
984
|
+
capture_raw_parser = subparsers.add_parser("capture-raw")
|
|
985
|
+
capture_raw_parser.add_argument(
|
|
986
|
+
"--output",
|
|
987
|
+
default="docs/real-codex-payload.json",
|
|
988
|
+
help="Path where the raw hook JSON should be written.",
|
|
989
|
+
)
|
|
990
|
+
capture_raw_parser.set_defaults(func=capture_raw)
|
|
991
|
+
|
|
992
|
+
upload_parser = subparsers.add_parser("upload")
|
|
993
|
+
upload_parser.add_argument(
|
|
994
|
+
"--api-url",
|
|
995
|
+
default=None,
|
|
996
|
+
)
|
|
997
|
+
upload_parser.add_argument("--token", default=None)
|
|
998
|
+
upload_parser.add_argument("--config-path")
|
|
999
|
+
upload_parser.add_argument("--queue-path")
|
|
1000
|
+
upload_parser.add_argument("--limit", type=int, default=100)
|
|
1001
|
+
upload_parser.add_argument("--timeout", type=float, default=10)
|
|
1002
|
+
upload_parser.add_argument(
|
|
1003
|
+
"--watch",
|
|
1004
|
+
action="store_true",
|
|
1005
|
+
help="Continuously upload queued events without blocking hooks.",
|
|
1006
|
+
)
|
|
1007
|
+
upload_parser.add_argument(
|
|
1008
|
+
"--interval",
|
|
1009
|
+
type=float,
|
|
1010
|
+
default=float(os.environ.get("PROMPTHUB_UPLOAD_INTERVAL", "2")),
|
|
1011
|
+
help="Seconds between queue checks in watch mode.",
|
|
1012
|
+
)
|
|
1013
|
+
upload_parser.set_defaults(func=upload)
|
|
1014
|
+
|
|
1015
|
+
login_parser = subparsers.add_parser("login")
|
|
1016
|
+
login_parser.add_argument("--app-url")
|
|
1017
|
+
login_parser.add_argument("--api-url")
|
|
1018
|
+
login_parser.add_argument("--callback-port", type=int, default=0)
|
|
1019
|
+
login_parser.add_argument("--config-path")
|
|
1020
|
+
login_parser.add_argument("--no-browser", action="store_true")
|
|
1021
|
+
login_parser.add_argument("--timeout", type=float, default=180)
|
|
1022
|
+
login_parser.add_argument("--token")
|
|
1023
|
+
login_parser.add_argument("--username")
|
|
1024
|
+
login_parser.set_defaults(func=login)
|
|
1025
|
+
|
|
1026
|
+
install_hooks_parser = subparsers.add_parser("install-hooks")
|
|
1027
|
+
install_hooks_parser.add_argument(
|
|
1028
|
+
"--tool",
|
|
1029
|
+
choices=sorted(INSTALL_TOOL_ALIASES),
|
|
1030
|
+
default="codex-cli",
|
|
1031
|
+
help="Hook integration to install (Codex CLI, Claude Code, or all).",
|
|
1032
|
+
)
|
|
1033
|
+
install_hooks_parser.add_argument(
|
|
1034
|
+
"--source",
|
|
1035
|
+
choices=("claude", "codex"),
|
|
1036
|
+
help="Deprecated alias for --tool",
|
|
1037
|
+
)
|
|
1038
|
+
install_hooks_parser.add_argument("--repo-root")
|
|
1039
|
+
install_hooks_parser.add_argument(
|
|
1040
|
+
"--hook-command",
|
|
1041
|
+
help="Command prefix that the AI tool should call before the hook subcommand.",
|
|
1042
|
+
)
|
|
1043
|
+
install_hooks_parser.set_defaults(func=install_hooks)
|
|
1044
|
+
|
|
1045
|
+
start_uploader_parser = subparsers.add_parser("start-uploader")
|
|
1046
|
+
start_uploader_parser.add_argument("--api-url")
|
|
1047
|
+
start_uploader_parser.add_argument("--config-path")
|
|
1048
|
+
start_uploader_parser.add_argument("--interval", type=float, default=2)
|
|
1049
|
+
start_uploader_parser.add_argument("--log-path")
|
|
1050
|
+
start_uploader_parser.add_argument("--pid-path")
|
|
1051
|
+
start_uploader_parser.add_argument("--token")
|
|
1052
|
+
start_uploader_parser.set_defaults(func=start_uploader)
|
|
1053
|
+
|
|
1054
|
+
doctor_parser = subparsers.add_parser("doctor")
|
|
1055
|
+
doctor_parser.add_argument("--api-url")
|
|
1056
|
+
doctor_parser.add_argument("--config-path")
|
|
1057
|
+
doctor_parser.add_argument("--pid-path")
|
|
1058
|
+
doctor_parser.add_argument("--queue-path")
|
|
1059
|
+
doctor_parser.add_argument("--repo-root")
|
|
1060
|
+
doctor_parser.add_argument("--timeout", type=float, default=3)
|
|
1061
|
+
doctor_parser.add_argument("--token")
|
|
1062
|
+
doctor_parser.add_argument(
|
|
1063
|
+
"--tool",
|
|
1064
|
+
choices=sorted(INSTALL_TOOL_ALIASES),
|
|
1065
|
+
default="codex-cli",
|
|
1066
|
+
)
|
|
1067
|
+
doctor_parser.set_defaults(func=doctor)
|
|
1068
|
+
|
|
1069
|
+
init_parser = subparsers.add_parser("init")
|
|
1070
|
+
init_parser.add_argument("--app-url")
|
|
1071
|
+
init_parser.add_argument("--api-url")
|
|
1072
|
+
init_parser.add_argument("--callback-port", type=int, default=0)
|
|
1073
|
+
init_parser.add_argument("--config-path")
|
|
1074
|
+
init_parser.add_argument("--hook-command")
|
|
1075
|
+
init_parser.add_argument("--login-timeout", type=float, default=180)
|
|
1076
|
+
init_parser.add_argument("--log-path")
|
|
1077
|
+
init_parser.add_argument("--no-browser", action="store_true")
|
|
1078
|
+
init_parser.add_argument("--pid-path")
|
|
1079
|
+
init_parser.add_argument("--repo-root")
|
|
1080
|
+
init_parser.add_argument("--skip-login", action="store_true")
|
|
1081
|
+
init_parser.add_argument("--skip-uploader", action="store_true")
|
|
1082
|
+
init_parser.add_argument("--token")
|
|
1083
|
+
init_parser.add_argument(
|
|
1084
|
+
"--tool",
|
|
1085
|
+
choices=sorted(INSTALL_TOOL_ALIASES),
|
|
1086
|
+
default="all",
|
|
1087
|
+
help="Hook integration to install (defaults to Codex CLI and Claude Code).",
|
|
1088
|
+
)
|
|
1089
|
+
init_parser.add_argument("--username")
|
|
1090
|
+
init_parser.add_argument("--upload-interval", type=float, default=2)
|
|
1091
|
+
init_parser.set_defaults(func=init)
|
|
1092
|
+
|
|
1093
|
+
return parser
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1097
|
+
parser = build_parser()
|
|
1098
|
+
args = parser.parse_args(argv)
|
|
1099
|
+
try:
|
|
1100
|
+
return args.func(args)
|
|
1101
|
+
except KeyboardInterrupt:
|
|
1102
|
+
print("Interrupted", file=sys.stderr)
|
|
1103
|
+
return 130
|
|
1104
|
+
except Exception as exc:
|
|
1105
|
+
print(f"Promty collector error: {exc}", file=sys.stderr)
|
|
1106
|
+
return 1
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
if __name__ == "__main__":
|
|
1110
|
+
raise SystemExit(main())
|