cynthia-cli 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.
cynthia/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """CYNTHIA — AI-native, local-first developer intelligence CLI.
2
+
3
+ CYNTHIA does not write code. It watches, remembers, and explains what is
4
+ happening across a developer's projects: git activity, file churn, TODOs,
5
+ and overall project health — all from a local SQLite store, no cloud
6
+ upload required.
7
+ """
8
+
9
+ __version__ = "0.1.0"
cynthia/cli.py ADDED
@@ -0,0 +1,442 @@
1
+ """CYNTHIA's command-line entry point.
2
+
3
+ Every subcommand here is a thin wrapper around `workspace` / `ui` —
4
+ the same functions the interactive shell (`shell.py`) calls — so the
5
+ two front ends never drift apart.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import sqlite3
11
+ import sys
12
+ from typing import Optional
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.text import Text
17
+
18
+ from . import config, health, overview, sessions, workspace
19
+ from .storage import Storage
20
+ from .ui import theme
21
+ from .ui.dashboard import render_status
22
+ from .ui.doctor import render_doctor
23
+ from .ui.focus import render_focus_analysis, render_sessions_table, render_similar_table
24
+ from .ui.overview import render_overview
25
+
26
+
27
+ def _configure_stdio() -> None:
28
+ """Use UTF-8 on Windows so Rich can print symbols and emoji."""
29
+ if sys.platform == "win32":
30
+ for stream in (sys.stdout, sys.stderr):
31
+ reconfigure = getattr(stream, "reconfigure", None)
32
+ if reconfigure:
33
+ try:
34
+ reconfigure(encoding="utf-8")
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ _configure_stdio()
40
+
41
+ app = typer.Typer(
42
+ name="cynthia",
43
+ help="CYNTHIA — a local-first developer intelligence CLI.",
44
+ no_args_is_help=False,
45
+ add_completion=False,
46
+ )
47
+ project_app = typer.Typer(help="Manage the active project for this session.")
48
+ focus_app = typer.Typer(
49
+ help="Focus sessions: start/stop tracking, or show K-Means work-mode breakdown.",
50
+ invoke_without_command=True,
51
+ no_args_is_help=False,
52
+ )
53
+ app.add_typer(project_app, name="project")
54
+ app.add_typer(focus_app, name="focus")
55
+
56
+ console = Console()
57
+
58
+ TAGLINE = "AI-native developer intelligence CLI"
59
+
60
+
61
+ def get_version() -> str:
62
+ """Return the package version from `cynthia.__version__`.
63
+
64
+ Falls back to "unknown" rather than raising, so `--version` never
65
+ dies with a traceback on a broken or partial install.
66
+ """
67
+ try:
68
+ from . import __version__
69
+
70
+ version = str(__version__).strip()
71
+ except Exception:
72
+ return "unknown"
73
+ return version or "unknown"
74
+
75
+
76
+ def _print_version() -> None:
77
+ line = Text()
78
+ line.append("CYNTHIA ", style=f"bold {theme.PRIMARY}")
79
+ line.append(get_version(), style=f"bold {theme.ACCENT}")
80
+ console.print(line)
81
+ console.print(Text(TAGLINE, style=f"italic {theme.SUBTEXT}"))
82
+
83
+
84
+ def _version_callback(value: bool) -> None:
85
+ if value:
86
+ _print_version()
87
+ raise typer.Exit()
88
+
89
+
90
+ @app.callback(invoke_without_command=True)
91
+ def main(
92
+ ctx: typer.Context,
93
+ version: bool = typer.Option(
94
+ False,
95
+ "--version",
96
+ "-v",
97
+ help="Show the CYNTHIA version and exit.",
98
+ callback=_version_callback,
99
+ is_eager=True,
100
+ ),
101
+ ) -> None:
102
+ if ctx.invoked_subcommand is None:
103
+ from .shell import run_shell
104
+
105
+ run_shell()
106
+
107
+
108
+ @app.command()
109
+ def init(name: str = typer.Argument("default", help="Workspace name")) -> None:
110
+ """Initialize a new CYNTHIA workspace in ~/.cynthia."""
111
+ workspace.init_workspace(name)
112
+ console.print(f"[green]\u2713[/green] Initialized workspace [bold]{name}[/bold] at [dim]{config.workspace_home()}[/dim]")
113
+
114
+
115
+ @app.command()
116
+ def add(
117
+ path: str = typer.Argument(..., help="Local path OR a git URL (https://, git@, ssh://) to clone"),
118
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Override the project name"),
119
+ ) -> None:
120
+ """Register an existing project, or clone-and-register a remote repo URL."""
121
+ try:
122
+ project = workspace.add_project(path, name)
123
+ except (
124
+ config.WorkspaceNotInitialized,
125
+ workspace.ProjectAlreadyExists,
126
+ workspace.CloneFailed,
127
+ FileNotFoundError,
128
+ ) as exc:
129
+ console.print(f"[red]Error:[/red] {exc}")
130
+ raise typer.Exit(1)
131
+ console.print(f"[green]\u2713[/green] Added project: [bold]{project.name}[/bold] [dim]{project.path}[/dim]")
132
+
133
+
134
+ @app.command(name="list")
135
+ def list_cmd() -> None:
136
+ """List every project registered in this workspace."""
137
+ try:
138
+ projects = workspace.list_projects()
139
+ except config.WorkspaceNotInitialized as exc:
140
+ console.print(f"[red]Error:[/red] {exc}")
141
+ raise typer.Exit(1)
142
+ if not projects:
143
+ console.print("[dim]No projects registered yet. Try 'cynthia add <path>'.[/dim]")
144
+ return
145
+ with Storage() as db:
146
+ active = db.active_project()
147
+ for p in projects:
148
+ marker = "\u25CF" if p.name == active else " "
149
+ console.print(f" {marker} [bold]{p.name}[/bold] [dim]{p.path}[/dim] [magenta]{p.kind}[/magenta]")
150
+
151
+
152
+ @app.command()
153
+ def remove(name: str = typer.Argument(..., help="Registered project name")) -> None:
154
+ """Remove a project from the workspace (does not touch files on disk)."""
155
+ try:
156
+ workspace.remove_project(name)
157
+ except (config.WorkspaceNotInitialized, workspace.ProjectNotFound) as exc:
158
+ console.print(f"[red]Error:[/red] {exc}")
159
+ raise typer.Exit(1)
160
+ console.print(f"[green]\u2713[/green] Removed project: [bold]{name}[/bold]")
161
+
162
+
163
+ @project_app.command("open")
164
+ def project_open(path_or_name: str = typer.Argument(..., help="Registered name, local path, or git URL")) -> None:
165
+ """Register (if new) and mark a project as active for this session."""
166
+ try:
167
+ project = workspace.open_project(path_or_name)
168
+ except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.CloneFailed) as exc:
169
+ console.print(f"[red]Error:[/red] {exc}")
170
+ raise typer.Exit(1)
171
+ console.print(f"[green]\u2713[/green] Opened project: [bold]{project.name}[/bold]")
172
+
173
+
174
+ @app.command()
175
+ def status(name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)")) -> None:
176
+ """Show the full health/activity dashboard for a project."""
177
+ try:
178
+ project = workspace.resolve_project(name)
179
+ snap = workspace.snapshot(project)
180
+ except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.NoActiveProject) as exc:
181
+ console.print(f"[red]Error:[/red] {exc}")
182
+ raise typer.Exit(1)
183
+ with Storage() as db:
184
+ db.touch_project(project.name)
185
+ render_status(console, snap, db)
186
+
187
+
188
+ @app.command()
189
+ def doctor(
190
+ name: Optional[str] = typer.Argument(
191
+ None, help="Project name (defaults to the active or most recently opened project)"
192
+ ),
193
+ ) -> None:
194
+ """Run a full diagnostic: health, repository, quality, activity, and risk."""
195
+ try:
196
+ project = workspace.resolve_project_or_recent(name)
197
+ report = health.build_health_report(project.id)
198
+ with Storage() as db:
199
+ db.touch_project(project.name)
200
+ except (
201
+ config.WorkspaceNotInitialized,
202
+ workspace.ProjectNotFound,
203
+ workspace.NoActiveProject,
204
+ health.UnknownProject,
205
+ ) as exc:
206
+ console.print(f"[red]Error:[/red] {exc}")
207
+ raise typer.Exit(1)
208
+ except sqlite3.DatabaseError as exc:
209
+ console.print(
210
+ f"[red]Error:[/red] the workspace database could not be read ({exc}).\n"
211
+ "[dim]If it is corrupted, move ~/.cynthia/workspace.db aside and run 'cynthia init'.[/dim]"
212
+ )
213
+ raise typer.Exit(1)
214
+ render_doctor(console, report)
215
+
216
+
217
+ @app.command("overview")
218
+ def overview_cmd() -> None:
219
+ """Show a multi-project workspace overview: health, activity, and branch."""
220
+ try:
221
+ data = overview.build_workspace_overview()
222
+ except config.WorkspaceNotInitialized as exc:
223
+ console.print(f"[red]Error:[/red] {exc}")
224
+ raise typer.Exit(1)
225
+ except sqlite3.DatabaseError as exc:
226
+ console.print(
227
+ f"[red]Error:[/red] the workspace database could not be read ({exc}).\n"
228
+ "[dim]If it is corrupted, move ~/.cynthia/workspace.db aside and run 'cynthia init'.[/dim]"
229
+ )
230
+ raise typer.Exit(1)
231
+ render_overview(console, data)
232
+
233
+
234
+ @app.command()
235
+ def log(
236
+ name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)"),
237
+ limit: int = typer.Option(20, "--limit", "-l", help="Number of commits to show"),
238
+ ) -> None:
239
+ """Show recent commit history for a project."""
240
+ from .git_observer import read_git_info
241
+
242
+ try:
243
+ project = workspace.resolve_project(name)
244
+ except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.NoActiveProject) as exc:
245
+ console.print(f"[red]Error:[/red] {exc}")
246
+ raise typer.Exit(1)
247
+
248
+ info = read_git_info(project.path, recent_limit=limit)
249
+ if not info.git_available:
250
+ console.print(
251
+ "[yellow]Git is not installed on this system — commit history is unavailable.[/yellow]\n"
252
+ "[dim]Local scanning (status, file counts, TODOs) still works without Git.[/dim]"
253
+ )
254
+ return
255
+ if not info.is_repo:
256
+ console.print("[yellow]Not a git repository.[/yellow]")
257
+ return
258
+ console.print(f"[bold]{project.name}[/bold] [dim]— {len(info.recent_commits)} commit(s)[/dim]\n")
259
+ for i, c in enumerate(info.recent_commits):
260
+ dot = theme.COMMIT_DOTS[i % len(theme.COMMIT_DOTS)]
261
+ console.print(
262
+ f"[{dot}]\u25CF[/{dot}] [cyan]{c.short_sha}[/cyan] {c.message} "
263
+ f"[magenta]{c.author}[/magenta] [dim]{c.relative_time}[/dim]"
264
+ )
265
+
266
+
267
+ @app.command()
268
+ def watch(name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)")) -> None:
269
+ """Watch a project in real time, logging file events into the workspace."""
270
+ try:
271
+ project = workspace.resolve_project(name)
272
+ except (config.WorkspaceNotInitialized, workspace.ProjectNotFound, workspace.NoActiveProject) as exc:
273
+ console.print(f"[red]Error:[/red] {exc}")
274
+ raise typer.Exit(1)
275
+
276
+ console.print(f"\U0001F441 Watching [bold]{project.name}[/bold] — press Ctrl+C to stop.\n")
277
+ asyncio.run(_watch_loop(project))
278
+
279
+
280
+ @focus_app.callback(invoke_without_command=True)
281
+ def focus_main(
282
+ ctx: typer.Context,
283
+ all_projects: bool = typer.Option(
284
+ False, "--all", help="Analyze sessions across every project"
285
+ ),
286
+ ) -> None:
287
+ """Show K-Means focus breakdown (or run a focus subcommand)."""
288
+ if ctx.invoked_subcommand is not None:
289
+ return
290
+ try:
291
+ result = sessions.analyze_sessions(all_projects=all_projects)
292
+ except workspace.NoActiveProject:
293
+ # Bare `cynthia focus` with no active project → analyze everything.
294
+ result = sessions.analyze_sessions(all_projects=True)
295
+ except (
296
+ config.WorkspaceNotInitialized,
297
+ workspace.ProjectNotFound,
298
+ ) as exc:
299
+ console.print(f"[red]Error:[/red] {exc}")
300
+ raise typer.Exit(1)
301
+ render_focus_analysis(console, result)
302
+
303
+
304
+ @focus_app.command("start")
305
+ def focus_start(
306
+ name: Optional[str] = typer.Argument(None, help="Project name (defaults to the active project)"),
307
+ ) -> None:
308
+ """Begin a focus session by snapshotting the current project state."""
309
+ try:
310
+ project = workspace.resolve_project(name)
311
+ result = sessions.start_focus(project)
312
+ except (
313
+ config.WorkspaceNotInitialized,
314
+ workspace.ProjectNotFound,
315
+ workspace.NoActiveProject,
316
+ sessions.FocusAlreadyActive,
317
+ ) as exc:
318
+ console.print(f"[red]Error:[/red] {exc}")
319
+ raise typer.Exit(1)
320
+ import datetime as dt
321
+
322
+ started = dt.datetime.fromtimestamp(result.started_at).strftime("%H:%M:%S")
323
+ console.print(
324
+ f"[green]\u2713[/green] Focus started on [bold]{result.project_name}[/bold] "
325
+ f"at [dim]{started}[/dim]"
326
+ )
327
+
328
+
329
+ @focus_app.command("stop")
330
+ def focus_stop() -> None:
331
+ """End the active focus session and store computed metrics."""
332
+ try:
333
+ session = sessions.stop_focus()
334
+ except (config.WorkspaceNotInitialized, sessions.NoActiveFocus) as exc:
335
+ console.print(f"[red]Error:[/red] {exc}")
336
+ raise typer.Exit(1)
337
+ except sessions.SessionTooShort as exc:
338
+ console.print(f"[yellow]{exc}[/yellow]")
339
+ return
340
+
341
+ console.print(
342
+ f"[green]\u2713[/green] Focus session recorded for "
343
+ f"[bold]{session.project_name}[/bold] "
344
+ f"([cyan]{session.duration_minutes:.1f}m[/cyan], "
345
+ f"{session.files_modified} files, "
346
+ f"+{session.lines_added}/-{session.lines_deleted} lines)"
347
+ )
348
+
349
+
350
+ @app.command("sessions")
351
+ def sessions_cmd(
352
+ args: Optional[list[str]] = typer.Argument(
353
+ None,
354
+ help="Optional project name, or 'analyze' [project]",
355
+ ),
356
+ all_projects: bool = typer.Option(
357
+ False, "--all", help="With analyze: use sessions from every project"
358
+ ),
359
+ ) -> None:
360
+ """List recorded focus sessions, or run work-mode analysis.
361
+
362
+ Examples:
363
+ cynthia sessions
364
+ cynthia sessions my-app
365
+ cynthia sessions analyze
366
+ cynthia sessions analyze my-app
367
+ cynthia sessions analyze --all
368
+ """
369
+ parts = list(args or [])
370
+ try:
371
+ if parts and parts[0] == "analyze":
372
+ name = parts[1] if len(parts) > 1 else None
373
+ result = sessions.analyze_sessions(
374
+ project_name=name if not all_projects else None,
375
+ all_projects=all_projects,
376
+ )
377
+ render_focus_analysis(console, result)
378
+ return
379
+
380
+ if all_projects and not parts:
381
+ # `--all` alone implies analyze across projects
382
+ result = sessions.analyze_sessions(all_projects=True)
383
+ render_focus_analysis(console, result)
384
+ return
385
+
386
+ name = parts[0] if parts else None
387
+ rows = sessions.list_focus_sessions(name)
388
+ render_sessions_table(console, rows)
389
+ except (
390
+ config.WorkspaceNotInitialized,
391
+ workspace.ProjectNotFound,
392
+ workspace.NoActiveProject,
393
+ ) as exc:
394
+ console.print(f"[red]Error:[/red] {exc}")
395
+ raise typer.Exit(1)
396
+
397
+
398
+ @app.command("similar")
399
+ def similar_cmd(
400
+ k: int = typer.Option(3, "--k", "-k", help="Number of similar sessions to return"),
401
+ ) -> None:
402
+ """Find historical sessions most similar to the current focus context (k-NN)."""
403
+ try:
404
+ result = sessions.similar_to_active_session(k=k)
405
+ except (
406
+ config.WorkspaceNotInitialized,
407
+ workspace.ProjectNotFound,
408
+ workspace.NoActiveProject,
409
+ ) as exc:
410
+ console.print(f"[red]Error:[/red] {exc}")
411
+ raise typer.Exit(1)
412
+ render_similar_table(console, result)
413
+
414
+
415
+ async def _watch_loop(project) -> None:
416
+ from .events import EventBus
417
+ from .watcher import ProjectWatcher
418
+
419
+ settings = config.Settings.load()
420
+ bus = EventBus()
421
+ bus.bind_loop(asyncio.get_running_loop())
422
+ watcher = ProjectWatcher(project.name, project.path, bus, ignored_dirs=set(settings.ignored_dirs))
423
+ watcher.start()
424
+
425
+ with Storage() as db:
426
+ try:
427
+ async for event in bus.subscribe():
428
+ db.log_event(project.id, event.type, event.payload)
429
+ path = event.payload.get("path", "")
430
+ console.print(f"[dim]{event.type}[/dim] {path}")
431
+ except asyncio.CancelledError:
432
+ pass
433
+ finally:
434
+ watcher.stop()
435
+
436
+
437
+ def run() -> None:
438
+ app()
439
+
440
+
441
+ if __name__ == "__main__":
442
+ run()
cynthia/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Workspace configuration: where CYNTHIA keeps its local state.
2
+
3
+ Everything lives under ~/.cynthia (overridable via the CYNTHIA_HOME env
4
+ var, mainly so tests don't touch a real home directory). Nothing here
5
+ ever talks to the network — CYNTHIA is local-first by design.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import tomli_w
15
+
16
+ DEFAULT_HOME = Path.home() / ".cynthia"
17
+
18
+
19
+ def workspace_home() -> Path:
20
+ override = os.environ.get("CYNTHIA_HOME")
21
+ return Path(override).expanduser() if override else DEFAULT_HOME
22
+
23
+
24
+ def db_path() -> Path:
25
+ return workspace_home() / "workspace.db"
26
+
27
+
28
+ def config_path() -> Path:
29
+ return workspace_home() / "config.toml"
30
+
31
+
32
+ @dataclass
33
+ class Settings:
34
+ """User-facing settings, persisted as TOML."""
35
+
36
+ workspace_name: str = "default"
37
+ theme: str = "gemini"
38
+ activity_window_days: int = 7
39
+ ignored_dirs: list[str] = field(
40
+ default_factory=lambda: [
41
+ ".git", "node_modules", "__pycache__", ".venv", "venv",
42
+ "dist", "build", ".mypy_cache", ".pytest_cache", ".idea",
43
+ ".vscode", "target", ".next", ".turbo",
44
+ ]
45
+ )
46
+
47
+ @classmethod
48
+ def load(cls) -> "Settings":
49
+ path = config_path()
50
+ if not path.exists():
51
+ return cls()
52
+ with open(path, "rb") as f:
53
+ data = tomllib.load(f)
54
+ return cls(**{**cls().__dict__, **data})
55
+
56
+ def save(self) -> None:
57
+ workspace_home().mkdir(parents=True, exist_ok=True)
58
+ with open(config_path(), "wb") as f:
59
+ tomli_w.dump(self.__dict__, f)
60
+
61
+
62
+ def is_initialized() -> bool:
63
+ return db_path().exists() and config_path().exists()
64
+
65
+
66
+ def ensure_initialized() -> None:
67
+ """Raise a clear, catchable error if `cynthia init` hasn't run yet."""
68
+ if not is_initialized():
69
+ raise WorkspaceNotInitialized(
70
+ "No CYNTHIA workspace found. Run 'cynthia init' first."
71
+ )
72
+
73
+
74
+ class WorkspaceNotInitialized(RuntimeError):
75
+ pass
cynthia/events.py ADDED
@@ -0,0 +1,91 @@
1
+ """Event Bus — the seam between "things happened" and "things are stored".
2
+
3
+ A tiny asyncio pub/sub. The File Watcher (running on a background thread,
4
+ per watchdog's design) publishes onto it via a thread-safe bridge; the
5
+ `cynthia watch` command's async loop subscribes and persists each event
6
+ through the Storage Layer. Kept separate from Storage on purpose: nothing
7
+ about "how do I react to a change" should need to know "how is a change
8
+ persisted", and vice versa.
9
+
10
+ `SyncEventCollector` is the same publish surface without an asyncio loop —
11
+ used by the interactive shell to count file changes during a focus session.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, AsyncIterator
20
+
21
+
22
+ @dataclass
23
+ class Event:
24
+ type: str # "file_created" | "file_modified" | "file_deleted" | "commit_detected" | ...
25
+ project_name: str
26
+ payload: dict[str, Any] = field(default_factory=dict)
27
+ timestamp: float = field(default_factory=time.time)
28
+
29
+
30
+ class EventBus:
31
+ def __init__(self) -> None:
32
+ self._queue: asyncio.Queue[Event] = asyncio.Queue()
33
+ self._loop: asyncio.AbstractEventLoop | None = None
34
+
35
+ def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
36
+ """Call once from within the running loop so other threads can publish safely."""
37
+ self._loop = loop
38
+
39
+ def publish(self, event: Event) -> None:
40
+ """Publish from within the event loop's own thread."""
41
+ self._queue.put_nowait(event)
42
+
43
+ def publish_threadsafe(self, event: Event) -> None:
44
+ """Publish from a *different* thread (e.g. a watchdog observer thread)."""
45
+ if self._loop is None:
46
+ raise RuntimeError("EventBus.bind_loop() must be called before publish_threadsafe()")
47
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
48
+
49
+ async def subscribe(self) -> AsyncIterator[Event]:
50
+ while True:
51
+ yield await self._queue.get()
52
+
53
+
54
+ class SyncEventCollector:
55
+ """Thread-safe sink with the same `publish_threadsafe` surface as EventBus.
56
+
57
+ Used by the interactive shell (no asyncio loop) to collect watcher events
58
+ and count unique file paths touched during a session.
59
+ """
60
+
61
+ def __init__(self) -> None:
62
+ self._lock = threading.Lock()
63
+ self._events: list[Event] = []
64
+
65
+ def publish_threadsafe(self, event: Event) -> None:
66
+ with self._lock:
67
+ self._events.append(event)
68
+
69
+ def publish(self, event: Event) -> None:
70
+ self.publish_threadsafe(event)
71
+
72
+ @property
73
+ def count(self) -> int:
74
+ with self._lock:
75
+ return len(self._events)
76
+
77
+ def unique_paths(self) -> set[str]:
78
+ with self._lock:
79
+ paths: set[str] = set()
80
+ for event in self._events:
81
+ path = event.payload.get("path")
82
+ if path:
83
+ paths.add(str(path))
84
+ dest = event.payload.get("dest_path")
85
+ if dest:
86
+ paths.add(str(dest))
87
+ return paths
88
+
89
+ def clear(self) -> None:
90
+ with self._lock:
91
+ self._events.clear()