aipromptdock 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.
- aipromptdock/__init__.py +8 -0
- aipromptdock/__main__.py +6 -0
- aipromptdock/api/__init__.py +1 -0
- aipromptdock/api/routes.py +54 -0
- aipromptdock/cli.py +82 -0
- aipromptdock/core/__init__.py +1 -0
- aipromptdock/core/events.py +42 -0
- aipromptdock/core/models.py +63 -0
- aipromptdock/core/store.py +109 -0
- aipromptdock/core/watcher.py +67 -0
- aipromptdock/providers/__init__.py +34 -0
- aipromptdock/providers/base.py +74 -0
- aipromptdock/providers/claude.py +143 -0
- aipromptdock/providers/codex.py +128 -0
- aipromptdock/providers/gemini.py +106 -0
- aipromptdock/server.py +106 -0
- aipromptdock/web/static/assets/index-KAPVLJa-.css +2 -0
- aipromptdock/web/static/assets/index-Vi3b67MB.js +10 -0
- aipromptdock/web/static/favicon.svg +35 -0
- aipromptdock/web/static/index.html +15 -0
- aipromptdock-0.1.0.dist-info/METADATA +181 -0
- aipromptdock-0.1.0.dist-info/RECORD +25 -0
- aipromptdock-0.1.0.dist-info/WHEEL +4 -0
- aipromptdock-0.1.0.dist-info/entry_points.txt +2 -0
- aipromptdock-0.1.0.dist-info/licenses/LICENSE +21 -0
aipromptdock/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""AI Prompt Dock — a local timeline for your AI CLI prompts."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("aipromptdock")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - source tree without installation
|
|
8
|
+
__version__ = "0.0.0"
|
aipromptdock/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API routes (filled in Phase 2)."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Versioned HTTP API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Query, Request
|
|
7
|
+
from sse_starlette.sse import EventSourceResponse
|
|
8
|
+
|
|
9
|
+
from aipromptdock.core.events import EventBroadcaster
|
|
10
|
+
from aipromptdock.core.models import PromptPage, ProviderId, ProviderInfo
|
|
11
|
+
from aipromptdock.core.store import PromptStore
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/v1")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _store(request: Request) -> PromptStore:
|
|
17
|
+
store: PromptStore = request.app.state.store
|
|
18
|
+
# Fingerprint-cheap: stats a handful of files, rescans only what changed.
|
|
19
|
+
# Keeps responses correct even before the file watcher has fired.
|
|
20
|
+
store.refresh()
|
|
21
|
+
return store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/providers")
|
|
25
|
+
def providers(request: Request) -> list[ProviderInfo]:
|
|
26
|
+
return _store(request).provider_infos()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/prompts")
|
|
30
|
+
def prompts(
|
|
31
|
+
request: Request,
|
|
32
|
+
provider: Annotated[ProviderId | None, Query()] = None,
|
|
33
|
+
q: Annotated[str | None, Query(max_length=500)] = None,
|
|
34
|
+
before: Annotated[str | None, Query(max_length=200)] = None,
|
|
35
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
36
|
+
) -> PromptPage:
|
|
37
|
+
return _store(request).page(provider=provider, q=q, before=before, limit=limit)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/events")
|
|
41
|
+
async def events(request: Request) -> EventSourceResponse:
|
|
42
|
+
"""Server-Sent Events: emits a `change` event whenever provider data changes."""
|
|
43
|
+
broadcaster: EventBroadcaster = request.app.state.broadcaster
|
|
44
|
+
|
|
45
|
+
async def stream() -> AsyncIterator[dict[str, Any]]:
|
|
46
|
+
queue = broadcaster.subscribe()
|
|
47
|
+
try:
|
|
48
|
+
while True:
|
|
49
|
+
provider_id = await queue.get()
|
|
50
|
+
yield {"event": "change", "data": provider_id}
|
|
51
|
+
finally:
|
|
52
|
+
broadcaster.unsubscribe(queue)
|
|
53
|
+
|
|
54
|
+
return EventSourceResponse(stream(), ping=15)
|
aipromptdock/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Command-line interface for AI Prompt Dock."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from aipromptdock import __version__
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="aipromptdock",
|
|
11
|
+
help=(
|
|
12
|
+
"A local timeline for every prompt you've sent to Claude Code, Codex CLI, and Gemini CLI."
|
|
13
|
+
),
|
|
14
|
+
add_completion=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version_callback(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"aipromptdock {__version__}")
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback(invoke_without_command=True)
|
|
25
|
+
def main(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Port to serve on.")] = 8745,
|
|
28
|
+
host: Annotated[
|
|
29
|
+
str, typer.Option("--host", help="Host to bind. Localhost-only by default.")
|
|
30
|
+
] = "127.0.0.1",
|
|
31
|
+
no_browser: Annotated[
|
|
32
|
+
bool, typer.Option("--no-browser", help="Do not open the browser automatically.")
|
|
33
|
+
] = False,
|
|
34
|
+
version: Annotated[
|
|
35
|
+
bool,
|
|
36
|
+
typer.Option(
|
|
37
|
+
"--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
38
|
+
),
|
|
39
|
+
] = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Serve the AI Prompt Dock web app (default command)."""
|
|
42
|
+
if ctx.invoked_subcommand is not None:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if host not in {"127.0.0.1", "localhost", "::1"}:
|
|
46
|
+
typer.secho(
|
|
47
|
+
"Warning: binding to a non-loopback host exposes your prompt history "
|
|
48
|
+
"to the network without authentication.",
|
|
49
|
+
fg="yellow",
|
|
50
|
+
err=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
from aipromptdock.server import serve
|
|
54
|
+
|
|
55
|
+
serve(host=host, port=port, open_browser=not no_browser)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def paths() -> None:
|
|
60
|
+
"""Doctor: show detected CLIs, their data locations, and prompt counts."""
|
|
61
|
+
from rich.console import Console
|
|
62
|
+
from rich.table import Table
|
|
63
|
+
|
|
64
|
+
from aipromptdock.core.store import PromptStore
|
|
65
|
+
from aipromptdock.providers import default_adapters
|
|
66
|
+
|
|
67
|
+
store = PromptStore(default_adapters())
|
|
68
|
+
store.refresh()
|
|
69
|
+
|
|
70
|
+
table = Table(title="AI Prompt Dock — provider detection")
|
|
71
|
+
table.add_column("Provider", style="bold")
|
|
72
|
+
table.add_column("Detected")
|
|
73
|
+
table.add_column("Prompts", justify="right")
|
|
74
|
+
table.add_column("Data read from")
|
|
75
|
+
for info in store.provider_infos():
|
|
76
|
+
table.add_row(
|
|
77
|
+
info.name,
|
|
78
|
+
"[green]yes[/green]" if info.detected else "[red]no[/red]",
|
|
79
|
+
str(info.prompt_count),
|
|
80
|
+
"\n".join(info.paths),
|
|
81
|
+
)
|
|
82
|
+
Console().print(table)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core domain: models, prompt store, file watcher (filled in Phase 1)."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Fan-out of provider-change notifications to SSE subscribers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from aipromptdock.core.models import ProviderId
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventBroadcaster:
|
|
12
|
+
"""Bridges watchdog worker threads to the asyncio SSE streams.
|
|
13
|
+
|
|
14
|
+
The watcher publishes from its own threads; ``publish_threadsafe`` hops
|
|
15
|
+
onto the server's event loop and fans the provider id out to every
|
|
16
|
+
subscribed queue.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._queues: set[asyncio.Queue[ProviderId]] = set()
|
|
21
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
22
|
+
|
|
23
|
+
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
24
|
+
self._loop = loop
|
|
25
|
+
|
|
26
|
+
def publish_threadsafe(self, provider_id: ProviderId) -> None:
|
|
27
|
+
loop = self._loop
|
|
28
|
+
if loop is None or loop.is_closed():
|
|
29
|
+
return
|
|
30
|
+
loop.call_soon_threadsafe(self._publish, provider_id)
|
|
31
|
+
|
|
32
|
+
def _publish(self, provider_id: ProviderId) -> None:
|
|
33
|
+
for queue in self._queues:
|
|
34
|
+
queue.put_nowait(provider_id)
|
|
35
|
+
|
|
36
|
+
def subscribe(self) -> asyncio.Queue[ProviderId]:
|
|
37
|
+
queue: asyncio.Queue[ProviderId] = asyncio.Queue()
|
|
38
|
+
self._queues.add(queue)
|
|
39
|
+
return queue
|
|
40
|
+
|
|
41
|
+
def unsubscribe(self, queue: asyncio.Queue[ProviderId]) -> None:
|
|
42
|
+
self._queues.discard(queue)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Domain models shared across providers, store, and API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
ProviderId = Literal["claude", "codex", "gemini"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def epoch_to_utc(value: float) -> datetime:
|
|
13
|
+
"""Convert a Unix epoch in seconds *or* milliseconds to an aware UTC datetime.
|
|
14
|
+
|
|
15
|
+
Claude Code records milliseconds, Codex CLI records seconds; anything above
|
|
16
|
+
1e11 (~5138 AD in seconds) is treated as milliseconds.
|
|
17
|
+
"""
|
|
18
|
+
if value > 1e11:
|
|
19
|
+
value /= 1000.0
|
|
20
|
+
return datetime.fromtimestamp(value, tz=UTC)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def iso_to_utc(value: str) -> datetime:
|
|
24
|
+
"""Parse an ISO-8601 string (Gemini/Claude transcripts use a trailing Z) to UTC."""
|
|
25
|
+
dt = datetime.fromisoformat(value)
|
|
26
|
+
if dt.tzinfo is None:
|
|
27
|
+
dt = dt.replace(tzinfo=UTC)
|
|
28
|
+
return dt.astimezone(UTC)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PromptEvent(BaseModel):
|
|
32
|
+
"""One prompt the user typed into an AI CLI."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
provider: ProviderId
|
|
36
|
+
text: str
|
|
37
|
+
timestamp: datetime
|
|
38
|
+
session_id: str | None = None
|
|
39
|
+
project: str | None = None
|
|
40
|
+
source: str
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def make_id(provider: str, session_id: str | None, timestamp: datetime, text: str) -> str:
|
|
44
|
+
"""Stable identifier, identical across rescans of the same data."""
|
|
45
|
+
raw = f"{provider}|{session_id or ''}|{timestamp.isoformat()}|{text}"
|
|
46
|
+
return hashlib.sha1(raw.encode("utf-8", "replace")).hexdigest()[:16]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProviderInfo(BaseModel):
|
|
50
|
+
"""Detection status of one provider, shown in tabs and the doctor command."""
|
|
51
|
+
|
|
52
|
+
id: ProviderId
|
|
53
|
+
name: str
|
|
54
|
+
detected: bool
|
|
55
|
+
prompt_count: int
|
|
56
|
+
paths: list[str]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PromptPage(BaseModel):
|
|
60
|
+
"""One page of timeline results."""
|
|
61
|
+
|
|
62
|
+
items: list[PromptEvent]
|
|
63
|
+
next_cursor: str | None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""In-memory prompt store: merge, sort, search, paginate, cache."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from aipromptdock.core.models import PromptEvent, PromptPage, ProviderId, ProviderInfo
|
|
8
|
+
from aipromptdock.providers.base import ProviderAdapter
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
DEFAULT_PAGE_SIZE = 50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sort_key(event: PromptEvent) -> tuple[datetime, str]:
|
|
16
|
+
return (event.timestamp, event.id)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PromptStore:
|
|
20
|
+
"""Holds all prompts in memory, newest first.
|
|
21
|
+
|
|
22
|
+
``refresh()`` is cheap when nothing changed: each adapter exposes a
|
|
23
|
+
stat-based fingerprint over the files it reads, and only adapters whose
|
|
24
|
+
fingerprint moved are rescanned. Thread-safe — the file watcher refreshes
|
|
25
|
+
from its own thread while the API reads.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, adapters: list[ProviderAdapter]) -> None:
|
|
29
|
+
self._adapters = adapters
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._events: list[PromptEvent] = []
|
|
32
|
+
self._by_provider: dict[ProviderId, list[PromptEvent]] = {}
|
|
33
|
+
self._fingerprints: dict[ProviderId, object] = {}
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def adapters(self) -> list[ProviderAdapter]:
|
|
37
|
+
return self._adapters
|
|
38
|
+
|
|
39
|
+
def refresh(self, force: bool = False) -> bool:
|
|
40
|
+
"""Rescan adapters whose data changed. Returns True if anything did."""
|
|
41
|
+
with self._lock:
|
|
42
|
+
changed = False
|
|
43
|
+
for adapter in self._adapters:
|
|
44
|
+
fingerprint = adapter.fingerprint()
|
|
45
|
+
if not force and self._fingerprints.get(adapter.id) == fingerprint:
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
events = adapter.scan()
|
|
49
|
+
except Exception: # defensive: a broken adapter must not kill the app
|
|
50
|
+
logger.exception("Provider %s failed to scan", adapter.id)
|
|
51
|
+
events = []
|
|
52
|
+
events.sort(key=_sort_key, reverse=True)
|
|
53
|
+
self._by_provider[adapter.id] = events
|
|
54
|
+
self._fingerprints[adapter.id] = fingerprint
|
|
55
|
+
changed = True
|
|
56
|
+
if changed:
|
|
57
|
+
merged = [e for events in self._by_provider.values() for e in events]
|
|
58
|
+
merged.sort(key=_sort_key, reverse=True)
|
|
59
|
+
self._events = merged
|
|
60
|
+
return changed
|
|
61
|
+
|
|
62
|
+
def page(
|
|
63
|
+
self,
|
|
64
|
+
provider: ProviderId | None = None,
|
|
65
|
+
q: str | None = None,
|
|
66
|
+
before: str | None = None,
|
|
67
|
+
limit: int = DEFAULT_PAGE_SIZE,
|
|
68
|
+
) -> PromptPage:
|
|
69
|
+
"""Newest-first page. ``before`` is the opaque cursor from the previous page."""
|
|
70
|
+
with self._lock:
|
|
71
|
+
events = self._by_provider.get(provider, []) if provider else self._events
|
|
72
|
+
|
|
73
|
+
if q:
|
|
74
|
+
needle = q.lower()
|
|
75
|
+
events = [e for e in events if needle in e.text.lower()]
|
|
76
|
+
|
|
77
|
+
if before:
|
|
78
|
+
cursor = _decode_cursor(before)
|
|
79
|
+
if cursor:
|
|
80
|
+
events = [e for e in events if _sort_key(e) < cursor]
|
|
81
|
+
|
|
82
|
+
items = events[:limit]
|
|
83
|
+
next_cursor = _encode_cursor(items[-1]) if len(events) > limit else None
|
|
84
|
+
return PromptPage(items=items, next_cursor=next_cursor)
|
|
85
|
+
|
|
86
|
+
def provider_infos(self) -> list[ProviderInfo]:
|
|
87
|
+
with self._lock:
|
|
88
|
+
return [
|
|
89
|
+
ProviderInfo(
|
|
90
|
+
id=adapter.id,
|
|
91
|
+
name=adapter.name,
|
|
92
|
+
detected=adapter.detected,
|
|
93
|
+
prompt_count=len(self._by_provider.get(adapter.id, [])),
|
|
94
|
+
paths=[str(p) for p in adapter.data_paths()[:3]] or [str(adapter.data_dir)],
|
|
95
|
+
)
|
|
96
|
+
for adapter in self._adapters
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _encode_cursor(event: PromptEvent) -> str:
|
|
101
|
+
return f"{event.timestamp.isoformat()}|{event.id}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decode_cursor(cursor: str) -> tuple[datetime, str] | None:
|
|
105
|
+
timestamp_raw, _, event_id = cursor.partition("|")
|
|
106
|
+
try:
|
|
107
|
+
return (datetime.fromisoformat(timestamp_raw), event_id)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Watch the providers' data directories and report (debounced) changes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
8
|
+
from watchdog.observers import Observer
|
|
9
|
+
|
|
10
|
+
from aipromptdock.core.models import ProviderId
|
|
11
|
+
from aipromptdock.providers.base import ProviderAdapter
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DEBOUNCE_SECONDS = 1.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Handler(FileSystemEventHandler):
|
|
19
|
+
def __init__(self, adapter: ProviderAdapter, notify: Callable[[ProviderId], None]) -> None:
|
|
20
|
+
self._adapter = adapter
|
|
21
|
+
self._notify = notify
|
|
22
|
+
self._timer: threading.Timer | None = None
|
|
23
|
+
self._timer_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def on_any_event(self, event: FileSystemEvent) -> None:
|
|
26
|
+
if event.is_directory:
|
|
27
|
+
return
|
|
28
|
+
path = str(event.src_path)
|
|
29
|
+
if not self._adapter.watch_filter(path):
|
|
30
|
+
return
|
|
31
|
+
# Debounce: CLIs write in bursts; fire once per quiet second.
|
|
32
|
+
with self._timer_lock:
|
|
33
|
+
if self._timer is not None:
|
|
34
|
+
self._timer.cancel()
|
|
35
|
+
self._timer = threading.Timer(DEBOUNCE_SECONDS, self._notify, args=(self._adapter.id,))
|
|
36
|
+
self._timer.daemon = True
|
|
37
|
+
self._timer.start()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DataWatcher:
|
|
41
|
+
"""One watchdog observer over all detected providers' data directories.
|
|
42
|
+
|
|
43
|
+
``on_change`` is called from a worker thread with the provider id after
|
|
44
|
+
each debounced burst of file events.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self, adapters: list[ProviderAdapter], on_change: Callable[[ProviderId], None]
|
|
49
|
+
) -> None:
|
|
50
|
+
self._observer = Observer()
|
|
51
|
+
self._started = False
|
|
52
|
+
for adapter in adapters:
|
|
53
|
+
if not adapter.detected:
|
|
54
|
+
continue
|
|
55
|
+
handler = _Handler(adapter, on_change)
|
|
56
|
+
self._observer.schedule(handler, str(adapter.data_dir), recursive=True)
|
|
57
|
+
self._started = True
|
|
58
|
+
|
|
59
|
+
def start(self) -> None:
|
|
60
|
+
if self._started:
|
|
61
|
+
self._observer.start()
|
|
62
|
+
logger.info("Watching provider data directories for changes")
|
|
63
|
+
|
|
64
|
+
def stop(self) -> None:
|
|
65
|
+
if self._started and self._observer.is_alive():
|
|
66
|
+
self._observer.stop()
|
|
67
|
+
self._observer.join(timeout=3)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Provider adapters for Claude Code, Codex CLI, and Gemini CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from aipromptdock.providers.base import ProviderAdapter
|
|
7
|
+
from aipromptdock.providers.claude import ClaudeAdapter
|
|
8
|
+
from aipromptdock.providers.codex import CodexAdapter
|
|
9
|
+
from aipromptdock.providers.gemini import GeminiAdapter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ClaudeAdapter",
|
|
13
|
+
"CodexAdapter",
|
|
14
|
+
"GeminiAdapter",
|
|
15
|
+
"ProviderAdapter",
|
|
16
|
+
"default_adapters",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _env_dir(var: str, default: Path) -> Path:
|
|
21
|
+
value = os.environ.get(var)
|
|
22
|
+
return Path(value).expanduser() if value else default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_adapters() -> list[ProviderAdapter]:
|
|
26
|
+
"""The three adapters, honoring each CLI's data-directory override."""
|
|
27
|
+
home = Path.home()
|
|
28
|
+
claude = ClaudeAdapter(_env_dir("CLAUDE_CONFIG_DIR", home / ".claude"))
|
|
29
|
+
codex = CodexAdapter(_env_dir("CODEX_HOME", home / ".codex"))
|
|
30
|
+
# Gemini directories are sha256(project path); paths known to Claude/Codex
|
|
31
|
+
# let us label Gemini prompts with real project names.
|
|
32
|
+
candidates = list(claude.iter_project_paths()) + list(codex.iter_project_paths())
|
|
33
|
+
gemini = GeminiAdapter(home / ".gemini", project_candidates=candidates)
|
|
34
|
+
return [claude, codex, gemini]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Provider adapter contract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aipromptdock.core.models import PromptEvent, ProviderId
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProviderAdapter(ABC):
|
|
16
|
+
"""Reads one CLI's on-disk prompt history.
|
|
17
|
+
|
|
18
|
+
Adapters must be defensive: the formats are undocumented internals of
|
|
19
|
+
third-party tools, so malformed lines are skipped (and logged at debug),
|
|
20
|
+
and a missing data directory simply means the provider is not detected.
|
|
21
|
+
Adapters never raise on bad data.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
id: ProviderId
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
def __init__(self, data_dir: Path) -> None:
|
|
28
|
+
self.data_dir = data_dir
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def detected(self) -> bool:
|
|
32
|
+
return self.data_dir.is_dir()
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def data_paths(self) -> list[Path]:
|
|
36
|
+
"""Existing files this provider reads (doctor output + change fingerprint)."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def scan(self) -> list[PromptEvent]:
|
|
40
|
+
"""Parse all prompts from disk."""
|
|
41
|
+
|
|
42
|
+
def watch_filter(self, path: str) -> bool:
|
|
43
|
+
"""Whether a filesystem event at ``path`` could change this provider's data."""
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def fingerprint(self) -> tuple[tuple[str, int, int], ...]:
|
|
47
|
+
"""Cheap stat-based change token over everything ``scan()`` reads."""
|
|
48
|
+
stats = []
|
|
49
|
+
for p in self.data_paths():
|
|
50
|
+
try:
|
|
51
|
+
st = p.stat()
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
stats.append((str(p), st.st_mtime_ns, st.st_size))
|
|
55
|
+
return tuple(sorted(stats))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def iter_jsonl(path: Path) -> Iterator[dict[str, Any]]:
|
|
59
|
+
"""Yield parsed objects from a JSON-Lines file, skipping malformed lines."""
|
|
60
|
+
try:
|
|
61
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
62
|
+
for line in fh:
|
|
63
|
+
line = line.strip()
|
|
64
|
+
if not line:
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
obj = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
logger.debug("Skipping malformed JSONL line in %s", path)
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(obj, dict):
|
|
72
|
+
yield obj
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
logger.debug("Cannot read %s: %s", path, exc)
|