agent-decoder 0.3.2__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.
Files changed (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
clau_decode/cli.py ADDED
@@ -0,0 +1,504 @@
1
+ """CLI entry point — `clau-decode [options] <command>`.
2
+
3
+ Commands:
4
+ dashboard Launch the web UI (default when no command given)
5
+ scan Rescan all paths and print summary
6
+ today Show today's token usage and cost
7
+ stats Print statistical metrics across all sessions
8
+ tips Print optimization tips
9
+ migrate Merge/relocate Claude history + configs across machines
10
+
11
+ Global options:
12
+ --path PATH Add an extra scan path (repeatable, appended to config paths)
13
+ --port PORT Override port (default: 4242)
14
+ --host HOST Bind host (default: 127.0.0.1)
15
+ --expose Bind to 0.0.0.0 with a security warning
16
+ --no-open Don't open browser on startup
17
+ --version Print version and exit
18
+ --enable-edit Enable message editing and deletion
19
+ --force-refresh Clear mtime cache and force full rescan
20
+ --since YYYYMMDD Only include sessions on or after this date
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import asyncio
27
+ import os
28
+ import sys
29
+ import threading
30
+ import time
31
+ import webbrowser
32
+ from datetime import date, datetime
33
+ from pathlib import Path
34
+
35
+ import uvicorn
36
+
37
+ from . import __version__, migrate
38
+ from .config import get_db_path, load_config
39
+ from .server import create_app
40
+
41
+
42
+ def _build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(
44
+ prog="clau-decode",
45
+ description="Local web viewer and analytics for AI coding assistant chat history",
46
+ )
47
+ parser.add_argument(
48
+ "--path",
49
+ action="append",
50
+ dest="paths",
51
+ metavar="PATH",
52
+ help="Add a scan path (repeatable)",
53
+ )
54
+ parser.add_argument(
55
+ "--port",
56
+ type=int,
57
+ default=None,
58
+ help="Override the listening port (default from config, fallback 4242)",
59
+ )
60
+ parser.add_argument(
61
+ "--host",
62
+ default=None,
63
+ help="Bind host (default: 127.0.0.1)",
64
+ )
65
+ parser.add_argument(
66
+ "--expose",
67
+ action="store_true",
68
+ default=False,
69
+ help="Bind to 0.0.0.0 (accessible on your local network). "
70
+ "Use with caution — anyone on the same network can view your data.",
71
+ )
72
+ parser.add_argument(
73
+ "--no-open",
74
+ action="store_true",
75
+ help="Don't open a browser window on startup",
76
+ )
77
+ parser.add_argument(
78
+ "--version",
79
+ action="version",
80
+ version=f"clau-decode {__version__}",
81
+ )
82
+ parser.add_argument(
83
+ "--enable-edit",
84
+ action="store_true",
85
+ default=False,
86
+ help="Enable message editing and deletion. A backup is created before every write.",
87
+ )
88
+ parser.add_argument(
89
+ "--force-refresh",
90
+ action="store_true",
91
+ default=False,
92
+ help="Clear mtime cache and force a full rescan of all session files.",
93
+ )
94
+ parser.add_argument(
95
+ "--since",
96
+ type=lambda s: datetime.strptime(s, "%Y%m%d").date(),
97
+ metavar="YYYYMMDD",
98
+ help="Only include sessions on or after this date",
99
+ )
100
+ parser.add_argument(
101
+ "--log-level",
102
+ choices=("debug", "info", "warning", "error"),
103
+ default="info",
104
+ help="Verbosity for clau_decode loggers (independent of uvicorn). "
105
+ "Default 'info' surfaces operational signals (PTY runner warnings, "
106
+ "/btw finalize/timeout, ephemeral persistence). Use 'debug' when "
107
+ "diagnosing a live issue.",
108
+ )
109
+ parser.add_argument(
110
+ "--demo",
111
+ nargs="?",
112
+ const="__AUTO__",
113
+ default=None,
114
+ metavar="PATH",
115
+ help="Launch in isolated demo mode. Ignores your real config and cache; "
116
+ "scans only the given demo dir (defaults to ./demo-data). Useful for "
117
+ "screen recordings.",
118
+ )
119
+
120
+ subparsers = parser.add_subparsers(dest="command")
121
+
122
+ subparsers.add_parser(
123
+ "dashboard",
124
+ help="Launch the web UI (default)",
125
+ )
126
+ subparsers.add_parser(
127
+ "scan",
128
+ help="Rescan all paths and print summary",
129
+ )
130
+ subparsers.add_parser(
131
+ "today",
132
+ help="Show today's token usage and cost",
133
+ )
134
+ subparsers.add_parser(
135
+ "stats",
136
+ help="Print statistical metrics across all sessions",
137
+ )
138
+ subparsers.add_parser(
139
+ "tips",
140
+ help="Print optimization tips",
141
+ )
142
+ migrate_parser = subparsers.add_parser(
143
+ "migrate",
144
+ help="Merge/relocate Claude chat history + configs across machines",
145
+ description=(
146
+ "Merge one or more Claude config trees into a destination ~/.claude, "
147
+ "rewriting a project-path prefix so chats stay viewable AND resumable. "
148
+ "Dry-run by default; --apply is gated behind --i-have-a-backup."
149
+ ),
150
+ )
151
+ migrate.add_arguments(migrate_parser)
152
+
153
+ return parser
154
+
155
+
156
+ def _resolve_host(args: argparse.Namespace, config=None) -> str:
157
+ """Determine the bind host: CLI flags > saved config > default."""
158
+ if args.expose:
159
+ print(
160
+ "WARNING: --expose binds to 0.0.0.0. "
161
+ "Anyone on your local network can access your chat history.\n"
162
+ )
163
+ return "0.0.0.0"
164
+ if args.host:
165
+ return args.host
166
+ if config is not None:
167
+ return config.host
168
+ return "127.0.0.1"
169
+
170
+
171
+ def _configure_clau_decode_logging(level_name: str) -> None:
172
+ """Attach an explicit stderr handler to the ``clau_decode`` logger.
173
+
174
+ Uvicorn's default ``log_config`` reconfigures Python logging when
175
+ ``uvicorn.run`` is invoked, which drops the implicit ``lastResort``
176
+ handler that would otherwise route our ``_log.warning(...)`` /
177
+ ``_log.info(...)`` calls to stderr. Without this, every PTY runner
178
+ signal (e.g. ``_finalize_btw_capture``, ``_btw_stuck_timeout``,
179
+ ``record_ephemeral_*`` failures) goes nowhere — verified during the
180
+ Phase 2 live smoke when 220 s of polling yielded zero log lines.
181
+
182
+ ``propagate=False`` keeps uvicorn's root handlers from emitting a
183
+ second copy of every line.
184
+ """
185
+ import logging as _logging
186
+
187
+ # When stderr is redirected to a file (e.g. ``nohup ... > /tmp/log 2>&1``)
188
+ # Python defaults to block-buffering, so log lines accumulate in-memory
189
+ # until shutdown. This was the second half of the Phase 2 smoke
190
+ # invisibility: even after we configured the handler, lines never reached
191
+ # the file until the process exited. Switch to line buffering so each
192
+ # ``\n`` flushes the underlying fd.
193
+ try:
194
+ sys.stderr.reconfigure(line_buffering=True) # type: ignore[attr-defined]
195
+ except Exception:
196
+ pass
197
+ level = getattr(_logging, level_name.upper(), _logging.INFO)
198
+ logger = _logging.getLogger("clau_decode")
199
+ logger.setLevel(level)
200
+ if not any(
201
+ isinstance(h, _logging.StreamHandler)
202
+ and getattr(h, "_clau_decode_attached", False)
203
+ for h in logger.handlers
204
+ ):
205
+ handler = _logging.StreamHandler(sys.stderr)
206
+ handler.setFormatter(
207
+ _logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
208
+ )
209
+ handler._clau_decode_attached = True # type: ignore[attr-defined]
210
+ logger.addHandler(handler)
211
+ logger.propagate = False
212
+
213
+
214
+ def _run_dashboard(args: argparse.Namespace, config) -> None:
215
+ """Launch the web UI with uvicorn."""
216
+ _configure_clau_decode_logging(args.log_level)
217
+ import logging as _logging
218
+
219
+ _logging.getLogger("clau_decode").info(
220
+ "logger configured (level=%s)", args.log_level
221
+ )
222
+ host = _resolve_host(args, config)
223
+
224
+ db_path = get_db_path()
225
+ db_path.parent.mkdir(parents=True, exist_ok=True)
226
+
227
+ if args.force_refresh:
228
+ asyncio.run(_force_refresh(db_path))
229
+
230
+ app = create_app(config, db_path)
231
+
232
+ if not args.no_open and config.auto_open_browser:
233
+ url = f"http://{host}:{config.port}"
234
+
235
+ def _open_browser() -> None:
236
+ import urllib.request
237
+
238
+ for _ in range(30):
239
+ time.sleep(0.5)
240
+ try:
241
+ urllib.request.urlopen(f"{url}/api/health", timeout=1)
242
+ break
243
+ except Exception:
244
+ continue
245
+ webbrowser.open(url)
246
+
247
+ threading.Thread(target=_open_browser, daemon=True).start()
248
+
249
+ print(f"Clau-Decode running at http://{host}:{config.port}", flush=True)
250
+ uvicorn.run(app, host=host, port=config.port, log_level="warning")
251
+
252
+
253
+ async def _force_refresh(db_path: Path) -> None:
254
+ """Clear all stored mtimes so the next scan re-parses every file."""
255
+ from .db import Database
256
+
257
+ async with Database(db_path) as db:
258
+ await db.init_schema()
259
+ await db.execute("UPDATE sessions SET file_mtime = NULL")
260
+ await db.commit()
261
+
262
+
263
+ async def _do_scan(db_path: Path, config) -> int:
264
+ """Scan all paths, return number of sessions indexed."""
265
+ from .db import Database
266
+ from .providers import register_builtins, registry
267
+
268
+ register_builtins()
269
+
270
+ count = 0
271
+ async with Database(db_path) as db:
272
+ await db.init_schema()
273
+ for adapter in registry.all_adapters():
274
+ roots = adapter.configured_roots(config)
275
+ async for project, session_path in adapter.discover(roots):
276
+ try:
277
+ session, messages = adapter.parse(session_path)
278
+ session.project_id = project.id
279
+ project.session_count += 1
280
+ current_mtime = session_path.stat().st_mtime
281
+ await db.upsert_project(project)
282
+ await db.upsert_session(session, file_mtime=current_mtime)
283
+ await db.upsert_messages(messages)
284
+ count += 1
285
+ except Exception as exc:
286
+ print(f" Warning: skipping {session_path}: {exc}")
287
+ return count
288
+
289
+
290
+ def _run_scan(args: argparse.Namespace, config) -> None:
291
+ """Rescan and print summary."""
292
+ db_path = get_db_path()
293
+ db_path.parent.mkdir(parents=True, exist_ok=True)
294
+
295
+ if args.force_refresh:
296
+ asyncio.run(_force_refresh(db_path))
297
+
298
+ print("Scanning...")
299
+ count = asyncio.run(_do_scan(db_path, config))
300
+ print(f"Indexed {count} sessions.")
301
+
302
+
303
+ def _run_today(args: argparse.Namespace, config) -> None:
304
+ """Show today's token usage and cost."""
305
+ from .db import Database
306
+ from .analytics.service import TokenAnalyticsService
307
+ from .analytics.cost import CostEngine
308
+ from .analytics.pricing import CachedPricingStrategy
309
+
310
+ db_path = get_db_path()
311
+ db_path.parent.mkdir(parents=True, exist_ok=True)
312
+
313
+ if args.force_refresh:
314
+ asyncio.run(_force_refresh(db_path))
315
+ asyncio.run(_do_scan(db_path, config))
316
+
317
+ async def _query():
318
+ async with Database(db_path) as db:
319
+ await db.init_schema()
320
+ messages = await db.get_all_messages()
321
+
322
+ today = args.since or date.today()
323
+ svc = TokenAnalyticsService()
324
+ buckets = svc.daily_buckets(messages)
325
+ target = next((b for b in buckets if b.day >= today), None)
326
+ if target is None:
327
+ return None
328
+ return target
329
+
330
+ result = asyncio.run(_query())
331
+ if result is None:
332
+ label = args.since.isoformat() if args.since else "today"
333
+ print(f"No usage data for {label}.")
334
+ return
335
+
336
+ bd = result.breakdown
337
+ print(f"Date: {result.day}")
338
+ print(f" Input tokens: {bd.input_tokens:,}")
339
+ print(f" Output tokens: {bd.output_tokens:,}")
340
+ print(f" Cache creation: {bd.cache_creation_tokens:,}")
341
+ print(f" Cache read: {bd.cache_read_tokens:,}")
342
+ print(f" Total tokens: {bd.total:,}")
343
+ print(f" Prompt count: {result.prompt_count}")
344
+
345
+ # Cost estimate
346
+ pricing = CachedPricingStrategy()
347
+ asyncio.run(pricing.refresh())
348
+ engine = CostEngine(pricing)
349
+ cost = engine.compute("claude-sonnet-4-6", bd)
350
+ print(f" Est. cost (Sonnet): ${float(cost.total_usd):.4f}")
351
+
352
+
353
+ def _run_stats(args: argparse.Namespace, config) -> None:
354
+ """Print statistical metrics."""
355
+ from .db import Database
356
+ from .analytics.stats import PromptStatsScanner, ModelUsageScanner, ToolUsageScanner
357
+
358
+ db_path = get_db_path()
359
+ db_path.parent.mkdir(parents=True, exist_ok=True)
360
+
361
+ if args.force_refresh:
362
+ asyncio.run(_force_refresh(db_path))
363
+ asyncio.run(_do_scan(db_path, config))
364
+
365
+ async def _query():
366
+ async with Database(db_path) as db:
367
+ await db.init_schema()
368
+ messages = await db.get_all_messages()
369
+ return messages
370
+
371
+ messages = asyncio.run(_query())
372
+
373
+ stats = PromptStatsScanner().scan(messages)
374
+ print("=== Prompt Stats ===")
375
+ for key, val in sorted(stats.items()):
376
+ if isinstance(val, float):
377
+ print(f" {key}: {val:.2f}")
378
+ else:
379
+ print(f" {key}: {val}")
380
+
381
+ models = ModelUsageScanner().scan(messages)
382
+ print("\n=== Model Usage ===")
383
+ for m in models:
384
+ print(
385
+ f" {m['model']}: {m['message_count']} messages, "
386
+ f"{m['input_tokens']:,} input, {m['output_tokens']:,} output"
387
+ )
388
+
389
+ tools = ToolUsageScanner().scan(messages)
390
+ print("\n=== Tool Usage ===")
391
+ for t in tools[:10]:
392
+ print(f" {t['tool']}: {t['count']} calls")
393
+
394
+
395
+ def _run_tips(args: argparse.Namespace, config) -> None:
396
+ """Print optimization tips."""
397
+ from .db import Database
398
+ from .analytics.tips import (
399
+ TipRegistry,
400
+ RepeatedFileReadRule,
401
+ OversizedToolResultRule,
402
+ LowCacheHitRule,
403
+ )
404
+
405
+ db_path = get_db_path()
406
+ db_path.parent.mkdir(parents=True, exist_ok=True)
407
+
408
+ if args.force_refresh:
409
+ asyncio.run(_force_refresh(db_path))
410
+ asyncio.run(_do_scan(db_path, config))
411
+
412
+ async def _query():
413
+ async with Database(db_path) as db:
414
+ await db.init_schema()
415
+ messages = await db.get_all_messages()
416
+ return messages
417
+
418
+ messages = asyncio.run(_query())
419
+
420
+ registry = TipRegistry()
421
+ registry.register(RepeatedFileReadRule())
422
+ registry.register(OversizedToolResultRule())
423
+ registry.register(LowCacheHitRule())
424
+ tips = registry.run(messages)
425
+
426
+ if not tips:
427
+ print("No optimization tips — looking good!")
428
+ return
429
+
430
+ for t in tips:
431
+ icon = {"info": "ℹ", "warning": "⚠", "critical": "✱"}.get(t.severity, "•")
432
+ print(f"{icon} [{t.severity.upper()}] {t.title}")
433
+ if t.detail:
434
+ print(f" {t.detail}")
435
+
436
+
437
+ def _apply_demo_mode(args: argparse.Namespace) -> None:
438
+ """Isolate config + cache + scan path so a demo launch can't pick up real state.
439
+
440
+ Must run before ``load_config`` and before any ``get_db_path``/``get_config_path``
441
+ call — those functions read ``XDG_CONFIG_HOME`` and ``XDG_CACHE_HOME`` at call
442
+ time, so overriding the env vars here is enough to redirect every consumer
443
+ (load_config, save_config, get_db_path) for the lifetime of the process.
444
+ """
445
+ if args.demo == "__AUTO__":
446
+ demo_path = Path.cwd() / "demo-data"
447
+ else:
448
+ demo_path = Path(args.demo).expanduser().resolve()
449
+
450
+ if not demo_path.exists() or not (demo_path / "projects").is_dir():
451
+ print(
452
+ f"--demo: '{demo_path}' is not a valid demo dir. "
453
+ "Expected a directory containing a 'projects/' subdirectory.",
454
+ file=sys.stderr,
455
+ )
456
+ sys.exit(2)
457
+
458
+ runtime = demo_path / ".runtime"
459
+ os.environ["XDG_CONFIG_HOME"] = str(runtime / "config")
460
+ os.environ["XDG_CACHE_HOME"] = str(runtime / "cache")
461
+ args.paths = [str(demo_path)]
462
+
463
+ print(f"Demo mode: scanning {demo_path} (runtime state at {runtime})")
464
+
465
+
466
+ def main() -> None:
467
+ """Entry point registered in pyproject.toml [project.scripts]."""
468
+ parser = _build_parser()
469
+ args = parser.parse_args()
470
+
471
+ if args.demo is not None:
472
+ _apply_demo_mode(args)
473
+
474
+ config = load_config(extra_paths=args.paths, port=args.port)
475
+
476
+ if args.demo is not None:
477
+ # Replace (not append) so neither the default '~/.claude' fallback nor
478
+ # any leftover profile entries can leak real session data into the demo.
479
+ config = config.model_copy(
480
+ update={
481
+ "data_paths": args.paths,
482
+ "profiles": [],
483
+ "active_profile_id": None,
484
+ }
485
+ )
486
+
487
+ if args.enable_edit:
488
+ config = config.model_copy(update={"edit_enabled": True})
489
+
490
+ command = args.command or "dashboard"
491
+ dispatch = {
492
+ "dashboard": _run_dashboard,
493
+ "scan": _run_scan,
494
+ "today": _run_today,
495
+ "stats": _run_stats,
496
+ "tips": _run_tips,
497
+ "migrate": _run_migrate,
498
+ }
499
+ dispatch[command](args, config)
500
+
501
+
502
+ def _run_migrate(args, config) -> None:
503
+ """``clau-decode migrate`` -- filesystem merge tool; ignores app config."""
504
+ raise SystemExit(migrate.run(args))
clau_decode/config.py ADDED
@@ -0,0 +1,144 @@
1
+ """User configuration — persisted to XDG_CONFIG_HOME/clau-decode/config.json.
2
+
3
+ Contract (for Agent 2 to implement):
4
+ load_config() -> AppConfig
5
+ - Read from config file if it exists, else return defaults
6
+ - Merge CLI-provided overrides (extra_paths, port)
7
+
8
+ save_config(config: AppConfig) -> None
9
+ - Atomically write config to the config file (write to .tmp, rename)
10
+
11
+ get_config_path() -> Path
12
+ - Return XDG_CONFIG_HOME/clau-decode/config.json (or ~/.config/clau-decode/config.json)
13
+
14
+ get_db_path() -> Path
15
+ - Return XDG_DATA_HOME/clau-decode/index.db (or ~/.local/share/clau-decode/index.db),
16
+ transparently migrating a legacy ~/.cache DB on first use
17
+
18
+ SOLID notes:
19
+ - No global state — callers hold the config object; this module is pure I/O
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import shutil
27
+ from pathlib import Path
28
+
29
+ from .models import AppConfig
30
+
31
+
32
+ def get_config_path() -> Path:
33
+ """Return the path to the clau-decode configuration file.
34
+
35
+ Respects the XDG Base Directory specification: uses ``XDG_CONFIG_HOME`` if
36
+ set, otherwise falls back to ``~/.config``.
37
+
38
+ Returns:
39
+ ``<xdg_config>/clau-decode/config.json``
40
+ """
41
+ xdg_config = os.environ.get("XDG_CONFIG_HOME", "") or str(
42
+ Path("~/.config").expanduser()
43
+ )
44
+ return Path(xdg_config) / "clau-decode" / "config.json"
45
+
46
+
47
+ def _legacy_cache_db_path() -> Path:
48
+ """The old DB location under the disposable cache dir (XDG_CACHE_HOME /
49
+ ~/.cache). Kept only to migrate data out of it — see ``get_db_path``."""
50
+ xdg_cache = os.environ.get("XDG_CACHE_HOME", "") or str(
51
+ Path("~/.cache").expanduser()
52
+ )
53
+ return Path(xdg_cache) / "clau-decode" / "index.db"
54
+
55
+
56
+ def get_db_path() -> Path:
57
+ """Return the path to the clau-decode SQLite database.
58
+
59
+ Stored under the DURABLE data dir (``XDG_DATA_HOME`` / ``~/.local/share``),
60
+ NOT the cache dir: the DB holds non-regenerable user intent (archived /
61
+ starred / viewed flags + custom titles in ``session_meta``), which an OS or
62
+ cache cleaner could otherwise wipe. The message index is regenerable but
63
+ lives here too so it persists across cache clears (no rescan needed).
64
+
65
+ On first use we transparently migrate a legacy ``~/.cache`` DB to the new
66
+ location (one-time copy, including any WAL/SHM sidecars so un-checkpointed
67
+ writes survive). The legacy file is left in place as a backstop.
68
+
69
+ Returns:
70
+ ``<xdg_data>/clau-decode/index.db``
71
+ """
72
+ xdg_data = os.environ.get("XDG_DATA_HOME", "") or str(
73
+ Path("~/.local/share").expanduser()
74
+ )
75
+ db_path = Path(xdg_data) / "clau-decode" / "index.db"
76
+
77
+ if not db_path.exists():
78
+ legacy = _legacy_cache_db_path()
79
+ if legacy.exists():
80
+ db_path.parent.mkdir(parents=True, exist_ok=True)
81
+ # Copy the main DB plus WAL/SHM so recent (un-checkpointed) writes
82
+ # — e.g. the latest archive/star — aren't lost in the move.
83
+ for suffix in ("", "-wal", "-shm"):
84
+ src = Path(str(legacy) + suffix)
85
+ if src.exists():
86
+ shutil.copy2(src, Path(str(db_path) + suffix))
87
+ return db_path
88
+
89
+
90
+ def load_config(
91
+ extra_paths: list[str] | None = None, port: int | None = None
92
+ ) -> AppConfig:
93
+ """Load config from disk and apply any CLI overrides.
94
+
95
+ If the config file does not exist, a default ``AppConfig()`` is returned.
96
+ CLI overrides are applied after loading:
97
+ - ``extra_paths``: appended to ``config.data_paths`` (deduplicated, order
98
+ preserved).
99
+ - ``port``: replaces ``config.port`` when provided.
100
+
101
+ Args:
102
+ extra_paths: Additional scan paths supplied via ``--path`` flags.
103
+ port: Port override supplied via ``--port`` flag.
104
+
105
+ Returns:
106
+ The resolved ``AppConfig``.
107
+ """
108
+ path = get_config_path()
109
+
110
+ if path.exists():
111
+ raw = path.read_text(encoding="utf-8")
112
+ config = AppConfig.model_validate(json.loads(raw))
113
+ else:
114
+ config = AppConfig()
115
+
116
+ if extra_paths:
117
+ existing = set(config.data_paths)
118
+ for p in extra_paths:
119
+ if p not in existing:
120
+ config.data_paths.append(p)
121
+ existing.add(p)
122
+
123
+ if port is not None:
124
+ config.port = port
125
+
126
+ return config
127
+
128
+
129
+ def save_config(config: AppConfig) -> None:
130
+ """Atomically persist config to disk.
131
+
132
+ Writes to a ``.tmp`` file first, then uses ``os.replace`` for an atomic
133
+ rename so readers never see a partial write. Parent directories are
134
+ created automatically.
135
+
136
+ Args:
137
+ config: The ``AppConfig`` to persist.
138
+ """
139
+ path = get_config_path()
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+
142
+ tmp_path = path.with_suffix(".tmp")
143
+ tmp_path.write_text(config.model_dump_json(indent=2), encoding="utf-8")
144
+ os.replace(tmp_path, path)