codebase-receipts-cli 1.0.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.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. receipts/verify/verifier.py +552 -0
@@ -0,0 +1,755 @@
1
+ """Interactive slash-command REPL — chat-style UX for Receipts.
2
+
3
+ A lightweight REPL using stdlib input() + typer echo for colors.
4
+ Every CLI subcommand has a slash equivalent: /check, /setup, /ingest,
5
+ /verify, /tui, /ama, /rewrite, /score, /dossier, /readiness, /export,
6
+ /ledger, /dashboard, plus /status, /help, /quit.
7
+ Fuzzy-matches mistyped commands and suggests the closest match.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from receipts.errors import ReceiptsError
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Command registry
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _COMMANDS: dict[str, Command] = {}
24
+
25
+
26
+ class Command:
27
+ def __init__(self, name: str, aliases: list[str], help_text: str, handler):
28
+ self.name = name
29
+ self.aliases = aliases
30
+ self.help_text = help_text
31
+ self.handler = handler
32
+
33
+ @property
34
+ def all_names(self) -> list[str]:
35
+ return [self.name] + self.aliases
36
+
37
+
38
+ def _register(name: str, aliases: list[str], help_text: str):
39
+ def decorator(fn):
40
+ cmd = Command(name, aliases, help_text, fn)
41
+ _COMMANDS[name] = cmd
42
+ for alias in aliases:
43
+ _COMMANDS[alias] = cmd
44
+ return fn
45
+
46
+ return decorator
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Helpers
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def _echo(msg: str, color: str | None = None, bold: bool = False) -> None:
55
+ typer.secho(msg, fg=color, bold=bold)
56
+
57
+
58
+ def _prompt(msg: str, default: str = "") -> str:
59
+ suffix = f" [{default}]" if default else ""
60
+ try:
61
+ val = input(f" {msg}{suffix}: ").strip()
62
+ except (EOFError, KeyboardInterrupt):
63
+ print()
64
+ return default
65
+ return val or default
66
+
67
+
68
+ def _prompt_path(msg: str, must_exist: bool = True) -> Path | None:
69
+ raw = _prompt(msg)
70
+ if not raw:
71
+ _echo(" (skipped)", color=typer.colors.YELLOW)
72
+ return None
73
+ p = Path(raw)
74
+ if must_exist and not p.exists():
75
+ _echo(f" Not found: {p}", color=typer.colors.RED)
76
+ return None
77
+ return p
78
+
79
+
80
+ def _prompt_int(msg: str, default: int = 5) -> int:
81
+ raw = _prompt(msg, str(default))
82
+ try:
83
+ return int(raw)
84
+ except ValueError:
85
+ return default
86
+
87
+
88
+ def _prompt_sections() -> list[str] | None:
89
+ raw = _prompt("Sections to filter (comma-separated, or Enter for all)")
90
+ if not raw:
91
+ return None
92
+ return [s.strip() for s in raw.split(",") if s.strip()]
93
+
94
+
95
+ def _prompt_source() -> str | None:
96
+ """Prompt for a codebase spec — local folder, git URL, or manifest alias.
97
+
98
+ Existence isn't checked here: URLs and aliases aren't paths. Resolution
99
+ (and the friendly known-aliases error) happens in the CLI backend.
100
+ """
101
+ raw = _prompt("Codebase (folder, git URL, or alias — already ingested)")
102
+ if not raw:
103
+ _echo(" (skipped)", color=typer.colors.YELLOW)
104
+ return None
105
+ return raw
106
+
107
+
108
+ def _check_provider_ready() -> bool:
109
+ """Quick check if the configured provider is reachable."""
110
+ try:
111
+ from receipts.config import load_settings
112
+ from receipts.llm.factory import get_provider
113
+
114
+ settings = load_settings()
115
+ get_provider(settings, verify=True)
116
+ return True
117
+ except Exception as exc:
118
+ _echo(f" Provider not ready: {exc}", color=typer.colors.RED)
119
+ _echo(
120
+ " Check your .env config and that your provider is running.",
121
+ color=typer.colors.YELLOW,
122
+ )
123
+ return False
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # /help
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ @_register("help", ["h", "?"], "Show available commands")
132
+ def _cmd_help(args: str) -> None:
133
+ _echo("")
134
+ _echo(" Available commands:", bold=True)
135
+ _echo("")
136
+
137
+ seen: set[str] = set()
138
+ for cmd in _COMMANDS.values():
139
+ if cmd.name in seen:
140
+ continue
141
+ seen.add(cmd.name)
142
+ aliases_str = f" ({', '.join(cmd.aliases)})" if cmd.aliases else ""
143
+ _echo(f" /{cmd.name:<10}{aliases_str}")
144
+ _echo(f" {cmd.help_text}")
145
+ _echo("")
146
+ _echo(" Type a command, or just start typing — I'll suggest the right one.")
147
+ _echo(
148
+ " Verification here uses the default evidence threshold (0.5);"
149
+ " use the CLI's --threshold flag to tune it.",
150
+ color=typer.colors.BRIGHT_BLACK,
151
+ )
152
+ _echo("")
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # /status
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ @_register("status", ["s", "info"], "Show provider, KB stats, and token spend")
161
+ def _cmd_status(args: str) -> None:
162
+ from receipts.config import load_settings
163
+ from receipts.ledger.token_ledger import TokenLedger
164
+
165
+ _echo("")
166
+ _echo(" Status", bold=True)
167
+ _echo("")
168
+
169
+ settings = load_settings()
170
+ _echo(f" Provider: {settings.provider}")
171
+ model_attr = {
172
+ "ollama": "ollama_chat_model",
173
+ "gemini": "gemini_chat_model",
174
+ "anthropic": "anthropic_chat_model",
175
+ "openai": "openai_chat_model",
176
+ }.get(settings.provider)
177
+ model = getattr(settings, model_attr, None) if model_attr else None
178
+ _echo(f" Model: {model or '(default)'}")
179
+
180
+ ready = _check_provider_ready()
181
+ if ready:
182
+ _echo(" Connected: Yes", color=typer.colors.GREEN)
183
+ else:
184
+ _echo(" Connected: No", color=typer.colors.RED)
185
+
186
+ tl = TokenLedger()
187
+ try:
188
+ lt = tl.lifetime_summary()
189
+ finally:
190
+ tl.close()
191
+
192
+ if lt.call_count > 0:
193
+ _echo("")
194
+ _echo(f" LLM calls: {lt.call_count}")
195
+ _echo(f" Input tokens: {lt.total_input:,}")
196
+ _echo(f" Output tokens: {lt.total_output:,}")
197
+ _echo(f" Est. cost: ${lt.total_cost:.4f}")
198
+ else:
199
+ _echo(" No LLM calls recorded yet.")
200
+ _echo("")
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # /check
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ @_register(
209
+ "check",
210
+ ["c", "connectivity"],
211
+ "Check provider configuration and connectivity (sends no content)",
212
+ )
213
+ def _cmd_check(args: str) -> None:
214
+ from receipts.cli import check as check_cmd
215
+
216
+ _echo("")
217
+ try:
218
+ check_cmd()
219
+ except (ReceiptsError, SystemExit, typer.Exit):
220
+ pass # the command already printed the failure in red
221
+ _echo("")
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # /setup
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ @_register(
230
+ "setup",
231
+ ["init", "configure"],
232
+ "First-time setup wizard — pick a provider, store your key, validate",
233
+ )
234
+ def _cmd_setup(args: str) -> None:
235
+ from receipts.cli import setup as setup_cmd
236
+
237
+ _echo("")
238
+ try:
239
+ setup_cmd()
240
+ except (ReceiptsError, SystemExit, typer.Exit):
241
+ pass # the wizard already printed what went wrong
242
+ except (typer.Abort, EOFError, KeyboardInterrupt):
243
+ _echo(" Setup cancelled.", color=typer.colors.YELLOW)
244
+ _echo("")
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # /ingest
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ @_register("ingest", ["i", "index"], "Ingest a codebase into the knowledge base")
253
+ def _cmd_ingest(args: str) -> None:
254
+ _echo("")
255
+ _echo(" Ingest a codebase", bold=True)
256
+ _echo("")
257
+
258
+ if args:
259
+ source_path = Path(args)
260
+ else:
261
+ source_path = _prompt_path(
262
+ "Path to codebase (folder or git URL)", must_exist=False
263
+ )
264
+ if source_path is None:
265
+ return
266
+
267
+ budget_raw = _prompt("Token budget (Enter for auto)")
268
+ token_budget = int(budget_raw) if budget_raw.strip().isdigit() else None
269
+
270
+ if not _check_provider_ready():
271
+ return
272
+
273
+ _echo("")
274
+ _echo(f" Ingesting: {source_path}")
275
+ budget_label = "auto" if token_budget is None else f"{token_budget:,} tokens"
276
+ _echo(f" Budget: {budget_label}")
277
+ _echo("")
278
+
279
+ from receipts.cli import _run_ingest
280
+ from receipts.ingest.git_source import cloned_repo, looks_like_git_url
281
+
282
+ source_str = str(source_path)
283
+ try:
284
+ if looks_like_git_url(source_str):
285
+ _echo(" Cloning...")
286
+ with cloned_repo(source_str) as root:
287
+ _run_ingest(root, token_budget)
288
+ else:
289
+ if not source_path.is_dir():
290
+ _echo(f" Not a folder: {source_path}", color=typer.colors.RED)
291
+ return
292
+ _run_ingest(source_path, token_budget)
293
+ except (ReceiptsError, SystemExit) as exc:
294
+ _echo(f" Error: {exc}", color=typer.colors.RED)
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # /verify
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ @_register("verify", ["v", "check-resume"], "Verify resume claims against the KB")
303
+ def _cmd_verify(args: str) -> None:
304
+ _echo("")
305
+ _echo(" Verify resume claims", bold=True)
306
+ _echo("")
307
+
308
+ resume_path = _prompt_path("Resume path (.tex or .pdf)")
309
+ if resume_path is None:
310
+ return
311
+
312
+ source_spec = _prompt_source()
313
+ if source_spec is None:
314
+ return
315
+
316
+ sections = _prompt_sections()
317
+ threshold = 0.5
318
+
319
+ do_rewrite = _prompt("Include rewrites? (y/n)", "n").lower().startswith("y")
320
+ jd_raw = _prompt("Job description file path (or Enter to skip)")
321
+ jd_path = jd_raw if jd_raw else None
322
+
323
+ if not _check_provider_ready():
324
+ return
325
+
326
+ _echo("")
327
+ from receipts.cli import _run_verify
328
+
329
+ try:
330
+ _run_verify(resume_path, source_spec, do_rewrite, jd_path, threshold, sections)
331
+ except (ReceiptsError, SystemExit) as exc:
332
+ _echo(f" Error: {exc}", color=typer.colors.RED)
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # /tui
337
+ # ---------------------------------------------------------------------------
338
+
339
+
340
+ @_register("tui", ["t"], "Launch the Textual verify TUI (panelled, streaming)")
341
+ def _cmd_tui(args: str) -> None:
342
+ _echo("")
343
+ _echo(" Verify TUI", bold=True)
344
+ _echo("")
345
+
346
+ resume_path = _prompt_path("Resume path (.tex or .pdf)")
347
+ if resume_path is None:
348
+ return
349
+
350
+ source_spec = _prompt_source()
351
+ if source_spec is None:
352
+ return
353
+
354
+ sections = _prompt_sections()
355
+
356
+ if not _check_provider_ready():
357
+ return
358
+
359
+ from receipts.cli import _resolve_entries, _resolve_kb_source
360
+ from receipts.tui.app import ReceiptsApp
361
+
362
+ try:
363
+ kb_dir, _ = _resolve_kb_source(source_spec)
364
+ # Same entry picker the CLI uses: with no section filter it lists the
365
+ # project/experience entries and asks which match the ingested repo.
366
+ entries = _resolve_entries(resume_path, sections, None, False)
367
+ tui_app = ReceiptsApp(
368
+ resume_path,
369
+ Path(source_spec),
370
+ threshold=0.5,
371
+ sections=sections,
372
+ entries=entries,
373
+ kb_dir=kb_dir,
374
+ )
375
+ tui_app.run()
376
+ except (ReceiptsError, SystemExit, typer.Exit) as exc:
377
+ _echo(f" Error: {exc}", color=typer.colors.RED)
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # /ama
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ @_register("ama", ["a", "interview", "mock"], "Start a mock interview session")
386
+ def _cmd_ama(args: str) -> None:
387
+ _echo("")
388
+ _echo(" Mock Interview (AMA)", bold=True)
389
+ _echo("")
390
+
391
+ resume_path = _prompt_path("Resume path (.tex or .pdf)")
392
+ if resume_path is None:
393
+ return
394
+
395
+ source_spec = _prompt_source()
396
+ if source_spec is None:
397
+ return
398
+
399
+ rounds = _prompt_int("Number of rounds", default=5)
400
+ sections = _prompt_sections()
401
+
402
+ use_tui = _prompt("Use TUI chat interface? (y/n)", "y").lower().startswith("y")
403
+
404
+ if not _check_provider_ready():
405
+ return
406
+
407
+ _echo("")
408
+ if use_tui:
409
+ from receipts.cli import _resolve_kb_source
410
+ from receipts.tui.ama_app import AmaApp
411
+
412
+ try:
413
+ kb_dir, _ = _resolve_kb_source(source_spec)
414
+ except ReceiptsError as exc:
415
+ _echo(f" Error: {exc}", color=typer.colors.RED)
416
+ return
417
+
418
+ ama_app = AmaApp(
419
+ resume_path,
420
+ Path(source_spec),
421
+ n_rounds=rounds,
422
+ threshold=0.5,
423
+ sections=sections,
424
+ kb_dir=kb_dir,
425
+ )
426
+ ama_app.run()
427
+ else:
428
+ from receipts.cli import _run_ama
429
+
430
+ try:
431
+ _run_ama(resume_path, source_spec, rounds, 0.5, sections)
432
+ except (ReceiptsError, SystemExit) as exc:
433
+ _echo(f" Error: {exc}", color=typer.colors.RED)
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # /rewrite
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ @_register(
442
+ "rewrite",
443
+ ["r", "rw", "optimize"],
444
+ "Rewrite weak bullets and generate optimized .tex",
445
+ )
446
+ def _cmd_rewrite(args: str) -> None:
447
+ _echo("")
448
+ _echo(" ATS-Optimized Rewrite", bold=True)
449
+ _echo("")
450
+
451
+ resume_path = _prompt_path("Resume path (.tex or .pdf)")
452
+ if resume_path is None:
453
+ return
454
+
455
+ source_path = _prompt_path("Codebase folder (already ingested)")
456
+ if source_path is None:
457
+ return
458
+
459
+ sections = _prompt_sections()
460
+ output_raw = _prompt("Output .tex path (or Enter for default)")
461
+ output_path = output_raw if output_raw else None
462
+
463
+ jd_raw = _prompt("Job description file (or Enter to skip)")
464
+ jd_path = jd_raw if jd_raw else None
465
+
466
+ compile_pdf = _prompt("Compile to PDF? (y/n)", "n").lower().startswith("y")
467
+
468
+ if not _check_provider_ready():
469
+ return
470
+
471
+ _echo("")
472
+ from receipts.cli import _run_rewrite_pipeline
473
+
474
+ try:
475
+ _run_rewrite_pipeline(
476
+ resume_path,
477
+ source_path,
478
+ output_path,
479
+ 0.5,
480
+ sections,
481
+ compile_pdf,
482
+ jd_path,
483
+ )
484
+ except (ReceiptsError, SystemExit) as exc:
485
+ _echo(f" Error: {exc}", color=typer.colors.RED)
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # /score
490
+ # ---------------------------------------------------------------------------
491
+
492
+
493
+ @_register("score", ["sc", "ats"], "Score resume against a JD (ATS scoring engine)")
494
+ def _cmd_score(args: str) -> None:
495
+ _echo("")
496
+ _echo(" ATS Score", bold=True)
497
+ _echo("")
498
+
499
+ resume_path = _prompt_path("Resume path (.tex or .pdf)")
500
+ if resume_path is None:
501
+ return
502
+
503
+ jd_path = _prompt_path("Job description file path")
504
+ if jd_path is None:
505
+ return
506
+
507
+ compare_raw = _prompt("Compare against another .tex? (path or Enter to skip)")
508
+ compare_path = compare_raw if compare_raw else None
509
+
510
+ show_stats = False
511
+ if compare_path:
512
+ show_stats = (
513
+ _prompt("Show statistical analysis? (y/n)", "y").lower().startswith("y")
514
+ )
515
+
516
+ _echo("")
517
+ from receipts.cli import _run_score
518
+
519
+ try:
520
+ _run_score(resume_path, jd_path, compare_path, show_stats)
521
+ except (ReceiptsError, SystemExit) as exc:
522
+ _echo(f" Error: {exc}", color=typer.colors.RED)
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # /dossier
527
+ # ---------------------------------------------------------------------------
528
+
529
+
530
+ @_register("dossier", ["d", "prep"], "Generate the interview defense dossier")
531
+ def _cmd_dossier(args: str) -> None:
532
+ _echo("")
533
+ _echo(" Interview Defense Dossier", bold=True)
534
+ _echo("")
535
+
536
+ resume_path = _prompt_path("Resume path (.tex or .pdf)")
537
+ if resume_path is None:
538
+ return
539
+
540
+ source_spec = _prompt_source()
541
+ if source_spec is None:
542
+ return
543
+
544
+ entries_raw = _prompt("Entry to cover (substring, or Enter for all)")
545
+ entries = [entries_raw] if entries_raw else None
546
+
547
+ output = _prompt("Output file", "dossier.md")
548
+
549
+ if not _check_provider_ready():
550
+ return
551
+
552
+ _echo("")
553
+ from receipts.cli import _run_dossier
554
+
555
+ try:
556
+ _run_dossier(resume_path, source_spec, output, 0.5, None, entries)
557
+ except (ReceiptsError, SystemExit) as exc:
558
+ _echo(f" Error: {exc}", color=typer.colors.RED)
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # /readiness
563
+ # ---------------------------------------------------------------------------
564
+
565
+
566
+ @_register("readiness", ["rd", "progress"], "Show mock-interview readiness trends")
567
+ def _cmd_readiness(args: str) -> None:
568
+ from receipts.cli import readiness as readiness_cmd
569
+
570
+ _echo("")
571
+ try:
572
+ readiness_cmd()
573
+ except (ReceiptsError, SystemExit) as exc:
574
+ _echo(f" Error: {exc}", color=typer.colors.RED)
575
+ _echo("")
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # /export
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ @_register("export", ["e", "pdf"], "Export a .md or .tex file to PDF")
584
+ def _cmd_export(args: str) -> None:
585
+ _echo("")
586
+ _echo(" Export to PDF", bold=True)
587
+ _echo("")
588
+
589
+ if args:
590
+ source_path = Path(args)
591
+ if not source_path.is_file():
592
+ _echo(f" Not found: {source_path}", color=typer.colors.RED)
593
+ return
594
+ else:
595
+ source_path = _prompt_path("File to export (.md or .tex)")
596
+ if source_path is None:
597
+ return
598
+
599
+ output_raw = _prompt("Output PDF path (or Enter for default)")
600
+
601
+ from receipts.cli import export as export_cmd
602
+
603
+ _echo("")
604
+ try:
605
+ # Pass both params explicitly — typer's OptionInfo defaults only
606
+ # resolve when invoked through the CLI, not in a direct call.
607
+ export_cmd(str(source_path), output_raw or None)
608
+ except (ReceiptsError, SystemExit, typer.Exit):
609
+ pass # the command already printed the failure
610
+ _echo("")
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # /ledger
615
+ # ---------------------------------------------------------------------------
616
+
617
+
618
+ @_register(
619
+ "ledger",
620
+ ["l", "tokens", "spend"],
621
+ "Show the token/cost ledger (lifetime or last session)",
622
+ )
623
+ def _cmd_ledger(args: str) -> None:
624
+ _echo("")
625
+ scope = _prompt("Lifetime or last session? (l/s)", "l").lower()
626
+
627
+ from receipts.cli import ledger as ledger_cmd
628
+
629
+ _echo("")
630
+ try:
631
+ ledger_cmd(session=scope.startswith("s"))
632
+ except (ReceiptsError, SystemExit, typer.Exit):
633
+ pass
634
+ _echo("")
635
+
636
+
637
+ # ---------------------------------------------------------------------------
638
+ # /dashboard
639
+ # ---------------------------------------------------------------------------
640
+
641
+
642
+ @_register(
643
+ "dashboard",
644
+ ["dash"],
645
+ "One-screen status TUI: provider, token spend, readiness",
646
+ )
647
+ def _cmd_dashboard(args: str) -> None:
648
+ from receipts.tui.hub import HubApp
649
+
650
+ try:
651
+ HubApp(start="dashboard").run()
652
+ except (ReceiptsError, SystemExit, typer.Exit) as exc:
653
+ _echo(f" Error: {exc}", color=typer.colors.RED)
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # /quit
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+ @_register("quit", ["q", "exit", "bye"], "Exit the interactive session")
662
+ def _cmd_quit(args: str) -> None:
663
+ _echo("")
664
+ _echo(" Later. Go crush that interview.", color=typer.colors.GREEN)
665
+ _echo("")
666
+ raise SystemExit(0)
667
+
668
+
669
+ # ---------------------------------------------------------------------------
670
+ # Fuzzy matching
671
+ # ---------------------------------------------------------------------------
672
+
673
+
674
+ def _find_command(raw: str) -> tuple[Command | None, str]:
675
+ """Match user input to a command. Returns (command, remaining_args)."""
676
+ raw = raw.strip()
677
+ if not raw:
678
+ return None, ""
679
+
680
+ if raw.startswith("/"):
681
+ raw = raw[1:]
682
+
683
+ parts = raw.split(None, 1)
684
+ cmd_str = parts[0].lower()
685
+ args = parts[1] if len(parts) > 1 else ""
686
+
687
+ if cmd_str in _COMMANDS:
688
+ return _COMMANDS[cmd_str], args
689
+
690
+ matches = difflib.get_close_matches(
691
+ cmd_str, list(_COMMANDS.keys()), n=1, cutoff=0.5
692
+ )
693
+ if matches:
694
+ return _COMMANDS[matches[0]], args
695
+
696
+ return None, raw
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # Main REPL loop
701
+ # ---------------------------------------------------------------------------
702
+
703
+ _BANNER = """
704
+ ┌─────────────────────────────────────────────┐
705
+ │ RECEIPTS — Interactive Mode │
706
+ │ │
707
+ │ Type /help for commands, /quit to exit. │
708
+ │ Just start typing — I'll figure it out. │
709
+ └─────────────────────────────────────────────┘
710
+ """
711
+
712
+
713
+ def run_repl() -> None:
714
+ """Start the interactive REPL session."""
715
+ _echo(_BANNER, color=typer.colors.CYAN, bold=True)
716
+
717
+ while True:
718
+ try:
719
+ raw = input(" receipts> ").strip()
720
+ except (EOFError, KeyboardInterrupt):
721
+ _echo("")
722
+ _cmd_quit("")
723
+ break
724
+
725
+ if not raw:
726
+ continue
727
+
728
+ cmd, args = _find_command(raw)
729
+
730
+ if cmd is None:
731
+ matches = difflib.get_close_matches(
732
+ raw.lstrip("/").split()[0].lower(),
733
+ list(_COMMANDS.keys()),
734
+ n=3,
735
+ cutoff=0.4,
736
+ )
737
+ if matches:
738
+ suggestions = ", ".join(f"/{m}" for m in matches)
739
+ _echo(
740
+ f" Unknown command. Did you mean: {suggestions}?",
741
+ color=typer.colors.YELLOW,
742
+ )
743
+ else:
744
+ _echo(
745
+ f" Unknown command: '{raw}'. Type /help for options.",
746
+ color=typer.colors.YELLOW,
747
+ )
748
+ continue
749
+
750
+ try:
751
+ cmd.handler(args)
752
+ except SystemExit:
753
+ break
754
+ except Exception as exc:
755
+ _echo(f" Unexpected error: {exc}", color=typer.colors.RED)