leads-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.
Files changed (72) hide show
  1. company_discovery/__init__.py +4 -0
  2. company_discovery/adapters/__init__.py +5 -0
  3. company_discovery/adapters/apollo.py +189 -0
  4. company_discovery/adapters/exa.py +112 -0
  5. company_discovery/adapters/llm.py +118 -0
  6. company_discovery/adapters/protocols.py +58 -0
  7. company_discovery/adapters/website.py +154 -0
  8. company_discovery/bundled_skills/__init__.py +1 -0
  9. company_discovery/bundled_skills/company-discovery-operator/SKILL.md +72 -0
  10. company_discovery/bundled_skills/company-discovery-operator/agents/openai.yaml +4 -0
  11. company_discovery/bundled_skills/company-enrichment-operator/SKILL.md +94 -0
  12. company_discovery/bundled_skills/company-enrichment-operator/agents/openai.yaml +4 -0
  13. company_discovery/bundled_skills/company-search-spec-writer/SKILL.md +109 -0
  14. company_discovery/bundled_skills/company-search-spec-writer/agents/openai.yaml +4 -0
  15. company_discovery/bundled_skills/contact-discovery-operator/SKILL.md +80 -0
  16. company_discovery/bundled_skills/contact-discovery-operator/agents/openai.yaml +4 -0
  17. company_discovery/bundled_skills/contact-enrichment-operator/SKILL.md +86 -0
  18. company_discovery/bundled_skills/contact-enrichment-operator/agents/openai.yaml +4 -0
  19. company_discovery/bundled_skills/contact-search-spec-writer/SKILL.md +86 -0
  20. company_discovery/bundled_skills/contact-search-spec-writer/agents/openai.yaml +4 -0
  21. company_discovery/bundled_skills/leads-update-operator/SKILL.md +60 -0
  22. company_discovery/bundled_skills/leads-update-operator/agents/openai.yaml +4 -0
  23. company_discovery/cli.py +1789 -0
  24. company_discovery/db/__init__.py +5 -0
  25. company_discovery/db/contact_enrichment_repository.py +268 -0
  26. company_discovery/db/contact_repository.py +366 -0
  27. company_discovery/db/enrichment_repository.py +207 -0
  28. company_discovery/db/models.py +324 -0
  29. company_discovery/db/repository.py +363 -0
  30. company_discovery/db/session.py +48 -0
  31. company_discovery/domain/__init__.py +24 -0
  32. company_discovery/domain/contact_models.py +178 -0
  33. company_discovery/domain/contact_spec.py +86 -0
  34. company_discovery/domain/models.py +287 -0
  35. company_discovery/domain/spec.py +263 -0
  36. company_discovery/migrations.py +190 -0
  37. company_discovery/prompts/__init__.py +8 -0
  38. company_discovery/prompts/candidate_evaluation/system.md +13 -0
  39. company_discovery/prompts/company_enrichment/system.md +42 -0
  40. company_discovery/prompts/contact_evaluation/system.md +18 -0
  41. company_discovery/prompts/query_generation/system.md +10 -0
  42. company_discovery/release_manifest.json +7 -0
  43. company_discovery/reports/__init__.py +4 -0
  44. company_discovery/reports/contact_enrichment_exporter.py +108 -0
  45. company_discovery/reports/contact_exporter.py +132 -0
  46. company_discovery/reports/enrichment_exporter.py +125 -0
  47. company_discovery/reports/exporter.py +135 -0
  48. company_discovery/runtime.py +336 -0
  49. company_discovery/services/__init__.py +4 -0
  50. company_discovery/services/contact_enrichment_pipeline.py +344 -0
  51. company_discovery/services/contact_enrichment_progress.py +37 -0
  52. company_discovery/services/contact_evaluator.py +110 -0
  53. company_discovery/services/contact_pipeline.py +295 -0
  54. company_discovery/services/contact_progress.py +38 -0
  55. company_discovery/services/enrichment_extractor.py +61 -0
  56. company_discovery/services/enrichment_pipeline.py +526 -0
  57. company_discovery/services/enrichment_progress.py +20 -0
  58. company_discovery/services/enrichment_resolver.py +148 -0
  59. company_discovery/services/evaluator.py +40 -0
  60. company_discovery/services/hygiene.py +51 -0
  61. company_discovery/services/memory.py +150 -0
  62. company_discovery/services/normalization.py +98 -0
  63. company_discovery/services/pipeline.py +628 -0
  64. company_discovery/services/progress.py +48 -0
  65. company_discovery/services/query_planner.py +47 -0
  66. company_discovery/settings.py +152 -0
  67. company_discovery/skill_installer.py +197 -0
  68. company_discovery/update_plan.py +79 -0
  69. leads_cli-0.1.0.dist-info/METADATA +277 -0
  70. leads_cli-0.1.0.dist-info/RECORD +72 -0
  71. leads_cli-0.1.0.dist-info/WHEEL +4 -0
  72. leads_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1789 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from importlib import metadata
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import questionary
12
+ import typer
13
+ from pydantic import ValidationError
14
+ from questionary import Choice, Style
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from company_discovery.adapters.exa import ExaClient
21
+ from company_discovery.adapters.apollo import ApolloClient
22
+ from company_discovery.adapters.llm import OpenAICompatibleLLM
23
+ from company_discovery.adapters.website import WebsiteClient
24
+ from company_discovery.db.enrichment_repository import (
25
+ EnrichmentRepository,
26
+ EnrichmentRunNotFoundError,
27
+ )
28
+ from company_discovery.db.contact_repository import (
29
+ ContactDiscoveryRepository,
30
+ ContactNotFoundError,
31
+ ContactRunNotFoundError,
32
+ )
33
+ from company_discovery.db.contact_enrichment_repository import (
34
+ ContactEnrichmentRepository,
35
+ ContactEnrichmentRunNotFoundError,
36
+ )
37
+ from company_discovery.db.repository import CandidateNotFoundError, DiscoveryRepository, RunNotFoundError
38
+ from company_discovery.db.session import Database
39
+ from company_discovery.domain.models import EnrichmentSummary, RunSummary
40
+ from company_discovery.domain.contact_models import (
41
+ ContactDiscoverySummary,
42
+ ContactEnrichmentSummary,
43
+ )
44
+ from company_discovery.domain.contact_spec import ContactSearchSpec
45
+ from company_discovery.domain.spec import CompanySearchSpec
46
+ from company_discovery.reports.exporter import ArtifactExporter
47
+ from company_discovery.reports.enrichment_exporter import EnrichmentArtifactExporter
48
+ from company_discovery.reports.contact_exporter import ContactDiscoveryArtifactExporter
49
+ from company_discovery.reports.contact_enrichment_exporter import (
50
+ ContactEnrichmentArtifactExporter,
51
+ )
52
+ from company_discovery.services.contact_evaluator import ContactEvaluator
53
+ from company_discovery.services.contact_pipeline import ContactDiscoveryPipeline
54
+ from company_discovery.services.contact_progress import ContactProgressReporter
55
+ from company_discovery.services.contact_enrichment_pipeline import (
56
+ ContactEnrichmentOptions,
57
+ ContactEnrichmentPipeline,
58
+ )
59
+ from company_discovery.services.contact_enrichment_progress import (
60
+ ContactEnrichmentProgressReporter,
61
+ )
62
+ from company_discovery.services.enrichment_extractor import EnrichmentExtractor
63
+ from company_discovery.services.enrichment_pipeline import EnrichmentOptions, EnrichmentPipeline
64
+ from company_discovery.services.enrichment_progress import EnrichmentProgressReporter
65
+ from company_discovery.services.evaluator import CandidateEvaluator
66
+ from company_discovery.services.normalization import canonical_domain
67
+ from company_discovery.services.pipeline import DiscoveryPipeline
68
+ from company_discovery.services.progress import ProgressReporter
69
+ from company_discovery.services.query_planner import QueryPlanner
70
+ from company_discovery.settings import Settings, get_settings
71
+ from company_discovery import __distribution_name__, __version__
72
+ from company_discovery.migrations import MigrationError, apply_migrations, migration_status
73
+ from company_discovery.runtime import (
74
+ SCHEMA_VERSION,
75
+ SKILL_BUNDLE_VERSION,
76
+ configure_workspace_logging,
77
+ default_workspace_root,
78
+ ensure_workspace,
79
+ merge_dicts,
80
+ read_toml,
81
+ update_config_value,
82
+ write_workspace_pointer,
83
+ )
84
+ from company_discovery.skill_installer import (
85
+ detect_targets,
86
+ install_skills,
87
+ installed_target_keys,
88
+ skill_status,
89
+ )
90
+ from company_discovery.update_plan import build_update_check
91
+
92
+
93
+ app = typer.Typer(no_args_is_help=True, help="Company targeting and discovery.")
94
+ companies = typer.Typer(no_args_is_help=True, help="Discover and inspect target companies.")
95
+ contacts = typer.Typer(
96
+ no_args_is_help=True,
97
+ help="Discover current people and enrich their contact channels.",
98
+ )
99
+ config_app = typer.Typer(no_args_is_help=True, help="Inspect and update local configuration.")
100
+ skills_app = typer.Typer(no_args_is_help=True, help="Install and inspect bundled agent skills.")
101
+ app.add_typer(companies, name="companies")
102
+ app.add_typer(contacts, name="contacts")
103
+ app.add_typer(config_app, name="config")
104
+ app.add_typer(skills_app, name="skills")
105
+ console = Console()
106
+
107
+ ONBOARDING_STYLE = Style(
108
+ [
109
+ ("qmark", "fg:#6ec6b8 bold"),
110
+ ("question", "bold"),
111
+ ("answer", "fg:#ffb000 bold"),
112
+ ("pointer", "fg:#ffffff bold"),
113
+ ("highlighted", "noreverse fg:#ffffff bold"),
114
+ ("selected", "noreverse fg:#ffffff"),
115
+ ("disabled", "fg:#8a9099"),
116
+ ("instruction", "fg:#c8cdd4"),
117
+ ("validation-toolbar", "noreverse fg:#ff6b6b bold"),
118
+ ]
119
+ )
120
+
121
+
122
+ LLM_PROVIDER_CHOICES = [
123
+ {
124
+ "key": "openai",
125
+ "label": "OpenAI",
126
+ "base_url": "https://api.openai.com/v1",
127
+ "supported": True,
128
+ "models": ["gpt-5-mini", "gpt-5", "gpt-4.1-mini"],
129
+ },
130
+ {
131
+ "key": "deepseek",
132
+ "label": "DeepSeek",
133
+ "base_url": "https://api.deepseek.com/v1",
134
+ "supported": True,
135
+ "models": ["deepseek-chat", "deepseek-reasoner"],
136
+ },
137
+ {
138
+ "key": "anthropic",
139
+ "label": "Anthropic Claude",
140
+ "base_url": "",
141
+ "supported": False,
142
+ "disabled": "native Anthropic API adapter is not implemented yet",
143
+ "models": [],
144
+ },
145
+ {
146
+ "key": "google-gemini",
147
+ "label": "Google Gemini",
148
+ "base_url": "",
149
+ "supported": False,
150
+ "disabled": "native Gemini API adapter is not implemented yet",
151
+ "models": [],
152
+ },
153
+ {
154
+ "key": "custom",
155
+ "label": "Custom OpenAI-compatible endpoint",
156
+ "base_url": "https://api.openai.com/v1",
157
+ "supported": True,
158
+ "models": [],
159
+ },
160
+ ]
161
+ CUSTOM_MODEL_VALUE = "__custom_model__"
162
+
163
+
164
+ class RichProgressReporter(ProgressReporter):
165
+ STYLES = {
166
+ "spec": ("SPEC", "blue"),
167
+ "memory": ("MEMORY", "green"),
168
+ "external": ("EXA", "bright_cyan"),
169
+ "evaluation": ("REVIEW", "yellow"),
170
+ "save": ("OUTPUT", "bright_green"),
171
+ }
172
+
173
+ def __init__(self, *, verbose: bool = False) -> None:
174
+ self.verbose = verbose
175
+ self._style = "white"
176
+
177
+ def stage(self, number: int, total: int, name: str, kind: str) -> None:
178
+ label, self._style = self.STYLES[kind]
179
+ title = Text(f"[{number}/{total}] {name}", style=f"bold {self._style}")
180
+ console.print(Panel(Text(label, style=f"bold {self._style}"), title=title, expand=False))
181
+
182
+ def info(self, message: str) -> None:
183
+ console.print(f" [{self._style}]*[/{self._style}] {message}")
184
+
185
+ def detail(self, message: str) -> None:
186
+ if self.verbose:
187
+ console.print(f" [dim]{message}[/dim]")
188
+
189
+ def query(self, current: int, total: int, query: str, raw_total: int) -> None:
190
+ suffix = f": {query}" if self.verbose else ""
191
+ console.print(
192
+ f" [bright_cyan]SEARCH[/bright_cyan] query {current}/{total}; "
193
+ f"{raw_total} raw results{suffix}"
194
+ )
195
+
196
+ def evaluation(
197
+ self,
198
+ current: int,
199
+ total: int,
200
+ selected: int,
201
+ reserve: int,
202
+ rejected: int,
203
+ detail: str | None = None,
204
+ ) -> None:
205
+ suffix = f"; {detail}" if self.verbose and detail else ""
206
+ console.print(
207
+ f" [yellow]REVIEW[/yellow] {current}/{total} | selected {selected} | "
208
+ f"reserve {reserve} | rejected {rejected}{suffix}"
209
+ )
210
+
211
+
212
+ class RichEnrichmentProgressReporter(EnrichmentProgressReporter):
213
+ COLORS = {
214
+ "INHERITED": "blue",
215
+ "MEMORY": "green",
216
+ "WEBSITE": "bright_cyan",
217
+ "FALLBACK": "yellow",
218
+ "READY": "bright_green",
219
+ "REVIEW": "yellow",
220
+ "BLOCKED": "red",
221
+ }
222
+
223
+ def start(self, discovery_run_id: str, total: int, bucket: str) -> None:
224
+ console.print(
225
+ Panel(
226
+ f"Discovery run [bold]{discovery_run_id}[/bold]\n"
227
+ f"{total} companies from [bold]{bucket}[/bold]",
228
+ title="Company enrichment",
229
+ border_style="bright_cyan",
230
+ )
231
+ )
232
+
233
+ def company(self, current: int, total: int, name: str) -> None:
234
+ console.print(f"\n[bold bright_cyan][{current}/{total}] {name}[/bold bright_cyan]")
235
+
236
+ def event(self, label: str, message: str) -> None:
237
+ color = self.COLORS.get(label, "white")
238
+ console.print(f" [{color}]{label:<9}[/{color}] {message}")
239
+
240
+
241
+ class RichContactProgressReporter(ContactProgressReporter):
242
+ def start(self, source_run_id: str, companies_count: int, roles: int) -> None:
243
+ console.print(
244
+ Panel(
245
+ f"Company enrichment run [bold]{source_run_id}[/bold]\n"
246
+ f"{companies_count} companies | {roles} role targets",
247
+ title="Contact discovery",
248
+ border_style="bright_cyan",
249
+ )
250
+ )
251
+
252
+ def company(self, current: int, total: int, name: str, domain: str) -> None:
253
+ console.print(
254
+ f"\n[bold bright_cyan][{current}/{total}] {name}[/bold bright_cyan] "
255
+ f"[dim]{domain}[/dim]"
256
+ )
257
+
258
+ def memory(self, role: str, reused: int, target: int) -> None:
259
+ console.print(
260
+ f" [green]MEMORY[/green] {role}: reused {reused}/{target}; "
261
+ f"live gap {max(0, target - reused)}"
262
+ )
263
+
264
+ def search(self, role: str, current: int, total: int, results: int) -> None:
265
+ console.print(
266
+ f" [bright_cyan]LIVE WEB[/bright_cyan] {role}: query {current}/{total}; "
267
+ f"{results} unique results"
268
+ )
269
+
270
+ def evaluation(self, role: str, accepted: int, review: int, rejected: int) -> None:
271
+ console.print(
272
+ f" [yellow]VERIFY[/yellow] {role}: accepted {accepted} | "
273
+ f"review {review} | rejected {rejected}"
274
+ )
275
+
276
+ def save(self, run_id: str) -> None:
277
+ console.print(f"\n [bright_green]OUTPUT[/bright_green] saved {run_id}")
278
+
279
+
280
+ class RichContactEnrichmentProgressReporter(ContactEnrichmentProgressReporter):
281
+ def start(self, source_run_id: str, contacts_count: int) -> None:
282
+ console.print(
283
+ Panel(
284
+ f"Contact discovery run [bold]{source_run_id}[/bold]\n"
285
+ f"{contacts_count} accepted contacts",
286
+ title="Apollo contact enrichment",
287
+ border_style="bright_cyan",
288
+ )
289
+ )
290
+
291
+ def memory(self, reused: int, pending: int) -> None:
292
+ console.print(
293
+ f" [green]MEMORY[/green] reused {reused} fresh profiles | "
294
+ f"Apollo gap {pending}"
295
+ )
296
+
297
+ def batch(self, current: int, total: int, size: int) -> None:
298
+ console.print(
299
+ f" [bright_cyan]APOLLO[/bright_cyan] batch {current}/{total} | {size} people"
300
+ )
301
+
302
+ def poll(self, request_id: str, attempt: int) -> None:
303
+ console.print(
304
+ f" [yellow]POLL[/yellow] {request_id} | attempt {attempt}"
305
+ )
306
+
307
+ def outcome(self, name: str, outcome: str, flags: list[str]) -> None:
308
+ color = {"ready": "bright_green", "review": "yellow", "blocked": "red"}[outcome]
309
+ suffix = f" | {', '.join(flags)}" if flags else ""
310
+ console.print(f" [{color}]{outcome.upper():<7}[/{color}] {name}{suffix}")
311
+
312
+ def save(self, run_id: str) -> None:
313
+ console.print(f"\n [bright_green]OUTPUT[/bright_green] saved {run_id}")
314
+
315
+ def build_runtime(settings: Settings) -> tuple[Database, DiscoveryRepository, DiscoveryPipeline, list[object]]:
316
+ settings.prepare_directories()
317
+ database = Database(settings.resolved_database_url)
318
+ database.create_schema()
319
+ repository = DiscoveryRepository(database)
320
+ resources: list[object] = []
321
+
322
+ llm = None
323
+ if settings.llm_api_key:
324
+ llm = OpenAICompatibleLLM(settings)
325
+ resources.append(llm)
326
+ exa = None
327
+ if settings.exa_api_key:
328
+ exa = ExaClient(settings)
329
+ resources.append(exa)
330
+
331
+ pipeline = DiscoveryPipeline(
332
+ repository=repository,
333
+ exporter=ArtifactExporter(settings.artifacts_dir),
334
+ query_planner=QueryPlanner(llm, settings.query_count) if llm else None,
335
+ evaluator=CandidateEvaluator(llm) if llm else None,
336
+ search_provider=exa,
337
+ results_per_query=settings.exa_results_per_query,
338
+ )
339
+ return database, repository, pipeline, resources
340
+
341
+
342
+ def build_enrichment_runtime(
343
+ settings: Settings,
344
+ ) -> tuple[Database, EnrichmentRepository, EnrichmentPipeline, list[object]]:
345
+ settings.prepare_directories()
346
+ database = Database(settings.resolved_database_url)
347
+ database.create_schema()
348
+ repository = EnrichmentRepository(database)
349
+ resources: list[object] = []
350
+
351
+ llm = OpenAICompatibleLLM(settings) if settings.llm_api_key else None
352
+ if llm:
353
+ resources.append(llm)
354
+ exa = ExaClient(settings) if settings.exa_api_key else None
355
+ if exa:
356
+ resources.append(exa)
357
+ website = WebsiteClient(
358
+ timeout_seconds=settings.enrichment_website_timeout_seconds,
359
+ max_pages=settings.enrichment_max_pages,
360
+ )
361
+ resources.append(website)
362
+ pipeline = EnrichmentPipeline(
363
+ repository=repository,
364
+ exporter=EnrichmentArtifactExporter(settings.artifacts_dir),
365
+ website=website,
366
+ extractor=EnrichmentExtractor(llm) if llm else None,
367
+ fallback_search=exa,
368
+ freshness_days=settings.enrichment_freshness_days,
369
+ fallback_results=settings.enrichment_fallback_results,
370
+ )
371
+ return database, repository, pipeline, resources
372
+
373
+
374
+ def build_contact_runtime(
375
+ settings: Settings,
376
+ ) -> tuple[Database, ContactDiscoveryRepository, ContactDiscoveryPipeline, list[object]]:
377
+ settings.prepare_directories()
378
+ database = Database(settings.resolved_database_url)
379
+ database.create_schema()
380
+ repository = ContactDiscoveryRepository(database)
381
+ resources: list[object] = []
382
+
383
+ llm = OpenAICompatibleLLM(settings) if settings.llm_api_key else None
384
+ if llm:
385
+ resources.append(llm)
386
+ exa = ExaClient(settings) if settings.exa_api_key else None
387
+ if exa:
388
+ resources.append(exa)
389
+ pipeline = ContactDiscoveryPipeline(
390
+ repository=repository,
391
+ exporter=ContactDiscoveryArtifactExporter(settings.artifacts_dir),
392
+ search_provider=exa,
393
+ evaluator=ContactEvaluator(llm) if llm else None,
394
+ results_per_query=settings.contact_results_per_query,
395
+ )
396
+ return database, repository, pipeline, resources
397
+
398
+
399
+ def build_contact_enrichment_runtime(
400
+ settings: Settings,
401
+ ) -> tuple[
402
+ Database,
403
+ ContactEnrichmentRepository,
404
+ ContactEnrichmentPipeline,
405
+ list[object],
406
+ ]:
407
+ settings.prepare_directories()
408
+ database = Database(settings.resolved_database_url)
409
+ database.create_schema()
410
+ repository = ContactEnrichmentRepository(database)
411
+ provider = ApolloClient(settings)
412
+ pipeline = ContactEnrichmentPipeline(
413
+ repository=repository,
414
+ exporter=ContactEnrichmentArtifactExporter(settings.artifacts_dir),
415
+ provider=provider,
416
+ freshness_days=settings.apollo_enrichment_freshness_days,
417
+ poll_interval_seconds=settings.apollo_poll_interval_seconds,
418
+ poll_timeout_seconds=settings.apollo_poll_timeout_seconds,
419
+ )
420
+ return database, repository, pipeline, [provider]
421
+
422
+
423
+ def build_contact_enrichment_repository(
424
+ settings: Settings,
425
+ ) -> tuple[Database, ContactEnrichmentRepository]:
426
+ settings.prepare_directories()
427
+ database = Database(settings.resolved_database_url)
428
+ database.create_schema()
429
+ return database, ContactEnrichmentRepository(database)
430
+
431
+
432
+ def close_runtime(database: Database, resources: list[object]) -> None:
433
+ for resource in resources:
434
+ close = getattr(resource, "close", None)
435
+ if close:
436
+ close()
437
+ database.dispose()
438
+
439
+
440
+ def _next_runs_archive_path(home: Path) -> Path:
441
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
442
+ base = home / f"runs-previousdb-{timestamp}"
443
+ candidate = base
444
+ suffix = 2
445
+ while candidate.exists():
446
+ candidate = home / f"{base.name}-{suffix}"
447
+ suffix += 1
448
+ return candidate
449
+
450
+
451
+ def _installed_cli_version() -> str:
452
+ try:
453
+ return metadata.version(__distribution_name__)
454
+ except metadata.PackageNotFoundError:
455
+ return __version__
456
+
457
+
458
+ def _mask_secrets(data: dict[str, object]) -> dict[str, object]:
459
+ masked = json.loads(json.dumps(data))
460
+ if isinstance(masked.get("llm"), dict) and masked["llm"].get("api_key"):
461
+ masked["llm"]["api_key"] = "********"
462
+ providers = masked.get("providers")
463
+ if isinstance(providers, dict):
464
+ for provider in providers.values():
465
+ if isinstance(provider, dict) and provider.get("api_key"):
466
+ provider["api_key"] = "********"
467
+ return masked
468
+
469
+
470
+ def _nested_value(data: dict[str, object], dotted_key: str, default: object = None) -> object:
471
+ cursor: object = data
472
+ for key in dotted_key.split("."):
473
+ if not isinstance(cursor, dict) or key not in cursor:
474
+ return default
475
+ cursor = cursor[key]
476
+ return cursor
477
+
478
+
479
+ def _provider_base_url(provider: str, current: str | None = None) -> str:
480
+ provider_choice = _provider_choice(provider)
481
+ if provider_choice and provider_choice["supported"] and provider_choice["key"] != "custom":
482
+ return str(provider_choice["base_url"])
483
+ return current or "https://api.openai.com/v1"
484
+
485
+
486
+ def _provider_choice(provider: str) -> dict[str, object] | None:
487
+ normalized = provider.strip().lower()
488
+ return next(
489
+ (
490
+ choice
491
+ for choice in LLM_PROVIDER_CHOICES
492
+ if choice["key"] == normalized or str(choice["label"]).lower() == normalized
493
+ ),
494
+ None,
495
+ )
496
+
497
+
498
+ def _interactive_terminal() -> bool:
499
+ return sys.stdin.isatty() and sys.stdout.isatty() and not os.getenv("LEADS_TEXT_PROMPTS")
500
+
501
+
502
+ def _desktop_workspace_root() -> Path:
503
+ return Path.home() / "Desktop" / "Leads"
504
+
505
+
506
+ def _workspace_choices(recommended: Path) -> list[dict[str, object]]:
507
+ desktop = _desktop_workspace_root()
508
+ return [
509
+ {
510
+ "key": "recommended",
511
+ "label": f"Recommended path ({recommended})",
512
+ "path": recommended,
513
+ },
514
+ {
515
+ "key": "desktop",
516
+ "label": f"Desktop ({desktop})",
517
+ "path": desktop,
518
+ },
519
+ {
520
+ "key": "custom",
521
+ "label": "Custom path",
522
+ "path": None,
523
+ },
524
+ ]
525
+
526
+
527
+ def _select_workspace_root(recommended: Path) -> Path:
528
+ choices = _workspace_choices(recommended)
529
+ selected = _select_choice("Workspace location", choices, default="recommended")
530
+ if selected == "custom":
531
+ return Path(_prompt_required("Custom workspace path")).expanduser()
532
+ choice = next(choice for choice in choices if choice["key"] == selected)
533
+ return Path(choice["path"]).expanduser()
534
+
535
+
536
+ def _select_choice(
537
+ message: str,
538
+ choices: list[dict[str, object]],
539
+ *,
540
+ default: str | None = None,
541
+ ) -> str:
542
+ if _interactive_terminal():
543
+ questionary_choices = [
544
+ Choice(
545
+ title=str(choice["label"]),
546
+ value=str(choice["key"]),
547
+ disabled=str(choice["disabled"]) if choice.get("disabled") else None,
548
+ )
549
+ for choice in choices
550
+ ]
551
+ answer = questionary.select(
552
+ message,
553
+ choices=questionary_choices,
554
+ default=default,
555
+ use_indicator=False,
556
+ use_shortcuts=False,
557
+ instruction="Use up/down and enter.",
558
+ style=ONBOARDING_STYLE,
559
+ ).ask()
560
+ if answer is None:
561
+ raise typer.Exit(130)
562
+ return str(answer)
563
+
564
+ enabled = [choice for choice in choices if not choice.get("disabled")]
565
+ options = ", ".join(str(choice["label"]) for choice in enabled)
566
+ while True:
567
+ answer = typer.prompt(f"{message} ({options})", default=default or str(enabled[0]["key"]))
568
+ selected = next(
569
+ (
570
+ choice
571
+ for choice in choices
572
+ if str(choice["key"]).lower() == answer.strip().lower()
573
+ or str(choice["label"]).lower() == answer.strip().lower()
574
+ ),
575
+ None,
576
+ )
577
+ if selected and not selected.get("disabled"):
578
+ return str(selected["key"])
579
+ if selected and selected.get("disabled"):
580
+ console.print(f"[yellow]{selected['label']} is not available yet: {selected['disabled']}[/yellow]")
581
+ else:
582
+ console.print(f"[yellow]Choose one of: {options}[/yellow]")
583
+
584
+
585
+ def _select_model(provider: str, current_model: str) -> str:
586
+ provider_choice = _provider_choice(provider)
587
+ models = list((provider_choice or {}).get("models", []))
588
+ example = models[0] if models else current_model or "my-model-name"
589
+ model_choices = [{"key": model, "label": model} for model in models]
590
+ model_choices.append(
591
+ {
592
+ "key": CUSTOM_MODEL_VALUE,
593
+ "label": f"Write my own model (e.g. {example})",
594
+ }
595
+ )
596
+ default = current_model if any(choice["key"] == current_model for choice in model_choices) else CUSTOM_MODEL_VALUE
597
+ selected = _select_choice("Default model", model_choices, default=default)
598
+ if selected == CUSTOM_MODEL_VALUE:
599
+ return _prompt_required("Model name", default=current_model if current_model else None)
600
+ return selected
601
+
602
+
603
+ def _select_skill_targets(detected_targets: list[object], detected_keys: list[str]) -> list[str]:
604
+ if _interactive_terminal():
605
+ choices = [
606
+ Choice(
607
+ title=f"{target.label} ({target.key}) -> {target.root}",
608
+ value=target.key,
609
+ checked=target.key in detected_keys,
610
+ )
611
+ for target in detected_targets
612
+ ]
613
+ answer = questionary.checkbox(
614
+ "Install skills into which agents?",
615
+ choices=choices,
616
+ instruction="Use up/down, space to select, enter to confirm.",
617
+ validate=lambda selected: bool(selected) or "Select at least one agent.",
618
+ style=ONBOARDING_STYLE,
619
+ ).ask()
620
+ if answer is None:
621
+ raise typer.Exit(130)
622
+ return [str(value) for value in answer]
623
+
624
+ while True:
625
+ default_selection = "detected" if detected_keys else ""
626
+ raw = typer.prompt(
627
+ "Install skills into which targets? (detected, all, or comma-separated keys)",
628
+ default=default_selection,
629
+ show_default=bool(default_selection),
630
+ )
631
+ selected = _parse_target_selection(raw, detected_keys)
632
+ if selected:
633
+ return selected
634
+ console.print("[yellow]Select at least one agent target.[/yellow]")
635
+
636
+
637
+ def _prompt_required(message: str, *, default: str | None = None, hide_input: bool = False) -> str:
638
+ while True:
639
+ value = typer.prompt(
640
+ message,
641
+ default=default or "",
642
+ hide_input=hide_input,
643
+ show_default=default is not None,
644
+ ).strip()
645
+ if value:
646
+ return value
647
+ console.print("[yellow]This value is required.[/yellow]")
648
+
649
+
650
+ def _prompt_masked_secret(message: str, *, required: bool = False) -> str:
651
+ if _interactive_terminal():
652
+ answer = questionary.password(
653
+ message,
654
+ validate=(lambda value: bool(value.strip()) or "This value is required.") if required else None,
655
+ style=ONBOARDING_STYLE,
656
+ ).ask()
657
+ if answer is None:
658
+ raise typer.Exit(130)
659
+ return answer.strip()
660
+ return typer.prompt(message, default="", hide_input=True, show_default=False).strip()
661
+
662
+
663
+ def _prompt_secret(label: str, *, existing: bool, required: bool = False) -> str | None:
664
+ suffix = " (leave blank to keep existing)" if existing else (" (required)" if required else " (optional)")
665
+ while True:
666
+ value = _prompt_masked_secret(f"{label}{suffix}", required=required and not existing)
667
+ if value or not required or existing:
668
+ break
669
+ console.print(f"[yellow]{label} is required.[/yellow]")
670
+ return value or None
671
+
672
+
673
+ def _parse_target_selection(raw: str, detected: list[str]) -> list[str]:
674
+ normalized = raw.strip().lower()
675
+ if not normalized or normalized == "none":
676
+ return []
677
+ if normalized == "all":
678
+ return [target.key for target in detect_targets()]
679
+ if normalized == "detected":
680
+ return detected
681
+ return [item.strip() for item in raw.split(",") if item.strip()]
682
+
683
+
684
+ @app.command("version")
685
+ def version(
686
+ json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
687
+ ) -> None:
688
+ """Show installed CLI, skill bundle, and schema versions."""
689
+ settings = get_settings()
690
+ payload = {
691
+ "product": "leads",
692
+ "cli_version": _installed_cli_version(),
693
+ "skill_bundle_version": SKILL_BUNDLE_VERSION,
694
+ "schema_version": SCHEMA_VERSION,
695
+ "workspace": str(settings.company_discovery_home),
696
+ }
697
+ if json_output:
698
+ console.print_json(data=payload)
699
+ return
700
+
701
+ table = Table(title="leads version", show_header=True, header_style="bold")
702
+ table.add_column("Component")
703
+ table.add_column("Version")
704
+ table.add_row("CLI", payload["cli_version"])
705
+ table.add_row("Skill bundle", payload["skill_bundle_version"])
706
+ table.add_row("DB schema", str(payload["schema_version"]))
707
+ table.add_row("Workspace", payload["workspace"])
708
+ console.print(table)
709
+
710
+
711
+ @app.command("doctor")
712
+ def doctor() -> None:
713
+ """Check local workspace, configuration, and database readiness."""
714
+ settings = get_settings()
715
+ paths = ensure_workspace(settings.company_discovery_home)
716
+ checks = [
717
+ ("Workspace", paths.root.exists(), paths.root),
718
+ ("Config", paths.config_file.exists(), paths.config_file),
719
+ ("Secrets", paths.secrets_file.exists(), paths.secrets_file),
720
+ ("Runtime metadata", paths.runtime_file.exists(), paths.runtime_file),
721
+ ("Database directory", paths.data_dir.exists(), paths.data_dir),
722
+ ("Runs directory", paths.runs_dir.exists(), paths.runs_dir),
723
+ ("Specs directory", paths.specs_dir.exists(), paths.specs_dir),
724
+ ("Backups directory", paths.backups_dir.exists(), paths.backups_dir),
725
+ ("Logs directory", paths.logs_dir.exists(), paths.logs_dir),
726
+ ("Log file", (paths.logs_dir / "leads.log").exists(), paths.logs_dir / "leads.log"),
727
+ ("Skills directory", paths.skills_dir.exists(), paths.skills_dir),
728
+ ("LLM API key", bool(settings.llm_api_key), "configured" if settings.llm_api_key else "missing"),
729
+ ("Exa API key", bool(settings.exa_api_key), "configured" if settings.exa_api_key else "optional"),
730
+ (
731
+ "Apollo API key",
732
+ bool(settings.apollo_api_key),
733
+ "configured" if settings.apollo_api_key else "optional",
734
+ ),
735
+ ]
736
+ table = Table(title="leads doctor", show_header=True, header_style="bold")
737
+ table.add_column("Check")
738
+ table.add_column("Status")
739
+ table.add_column("Detail")
740
+ for name, ok, detail in checks:
741
+ table.add_row(name, "[green]ok[/green]" if ok else "[yellow]attention[/yellow]", str(detail))
742
+ console.print(table)
743
+
744
+
745
+ @app.command("update")
746
+ def update(
747
+ check: Annotated[bool, typer.Option("--check", help="Inspect update requirements.")] = False,
748
+ apply_update: Annotated[
749
+ bool,
750
+ typer.Option("--apply", help="Reserved for the later safe apply workflow."),
751
+ ] = False,
752
+ json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
753
+ ) -> None:
754
+ """Guide or inspect the safe update workflow."""
755
+ settings = get_settings()
756
+ if check:
757
+ plan = build_update_check(settings)
758
+ if json_output:
759
+ console.print_json(data=plan)
760
+ return
761
+ table = Table(title="leads update check", show_header=True, header_style="bold")
762
+ table.add_column("Area")
763
+ table.add_column("Installed")
764
+ table.add_column("Target")
765
+ table.add_column("Action")
766
+ table.add_row(
767
+ "CLI",
768
+ plan["installed_cli_version"],
769
+ plan["latest_cli_version"],
770
+ "upgrade" if plan["cli_update_required"] else "none",
771
+ )
772
+ table.add_row(
773
+ "Skills",
774
+ str(plan["installed_skill_bundle_version"] or "not installed"),
775
+ plan["target_skill_bundle_version"],
776
+ "sync" if plan["skills_update_required"] else "none",
777
+ )
778
+ table.add_row(
779
+ "DB schema",
780
+ str(plan["current_db_schema_version"]),
781
+ str(plan["target_db_schema_version"]),
782
+ "migrate" if plan["migration_required"] else "none",
783
+ )
784
+ console.print(table)
785
+ console.print(f"Backup required: [bold]{'yes' if plan['backup_required'] else 'no'}[/bold]")
786
+ console.print(
787
+ f"User confirmation required: [bold]{'yes' if plan['confirmation_required'] else 'no'}[/bold]"
788
+ )
789
+ console.print(f"Risk summary: {plan['risk_summary']}")
790
+ return
791
+
792
+ if apply_update:
793
+ console.print(
794
+ Panel(
795
+ "The apply workflow is intentionally reserved for the migration and public install "
796
+ "phases. Run `leads update --check` first and review the plan before applying any "
797
+ "structural changes.",
798
+ title="Update apply not enabled yet",
799
+ border_style="yellow",
800
+ )
801
+ )
802
+ raise typer.Exit(2)
803
+
804
+ console.print(
805
+ Panel(
806
+ "We suggest updating this tool through one of your installed agents.\n\n"
807
+ "Why:\n"
808
+ "- the update may include CLI changes\n"
809
+ "- skills may need to be updated\n"
810
+ "- the database may need migration\n"
811
+ "- your agent can inspect the update plan and explain what will happen before applying changes\n\n"
812
+ "Suggested prompt:\n"
813
+ '"Please update my leads tool safely. First run `leads update --check`, explain whether '
814
+ "the CLI, skills, or database need changes, tell me what backups or migrations will "
815
+ 'happen, and ask for confirmation before applying anything structural."',
816
+ title="Safe leads update",
817
+ border_style="bright_cyan",
818
+ )
819
+ )
820
+
821
+
822
+ @app.command("migrate")
823
+ def migrate(
824
+ check: Annotated[bool, typer.Option("--check", help="Inspect database migration status.")] = False,
825
+ apply_migration: Annotated[
826
+ bool,
827
+ typer.Option("--apply", help="Apply the safe migration path when one is available."),
828
+ ] = False,
829
+ json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
830
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip interactive confirmation.")] = False,
831
+ ) -> None:
832
+ """Inspect or apply local database schema migrations."""
833
+ if check and apply_migration:
834
+ console.print("[bold red]Choose either --check or --apply, not both.[/bold red]")
835
+ raise typer.Exit(2)
836
+
837
+ settings = get_settings()
838
+ status = migration_status(settings)
839
+ if check or not apply_migration:
840
+ _render_migration_status(status.as_dict(), json_output=json_output)
841
+ return
842
+
843
+ if not status.can_apply:
844
+ _render_migration_status(status.as_dict(), json_output=json_output)
845
+ raise typer.Exit(2)
846
+ if status.backup_required and not yes:
847
+ confirmed = typer.confirm(
848
+ "This migration will create a timestamped database backup before applying. Continue?",
849
+ default=False,
850
+ )
851
+ if not confirmed:
852
+ console.print("Migration cancelled; nothing was changed.")
853
+ return
854
+ try:
855
+ result = apply_migrations(settings)
856
+ except MigrationError as exc:
857
+ console.print(f"[bold red]Migration failed:[/bold red] {exc}")
858
+ raise typer.Exit(1) from exc
859
+ if json_output:
860
+ console.print_json(data=result)
861
+ return
862
+ console.print(
863
+ Panel(
864
+ f"Action: [bold]{result['action']}[/bold]\n"
865
+ f"Schema: {result['current_schema_version']} -> {result['target_schema_version']}\n"
866
+ f"Backup: {result['backup_path'] or 'not required'}",
867
+ title="Migration complete",
868
+ border_style="bright_green",
869
+ )
870
+ )
871
+
872
+
873
+ def _render_migration_status(plan: dict[str, object], *, json_output: bool) -> None:
874
+ if json_output:
875
+ console.print_json(data=plan)
876
+ return
877
+ table = Table(title="leads migrate check", show_header=True, header_style="bold")
878
+ table.add_column("Area")
879
+ table.add_column("Current")
880
+ table.add_column("Target")
881
+ table.add_column("Action")
882
+ table.add_row(
883
+ "DB schema",
884
+ str(plan["current_schema_version"]),
885
+ str(plan["target_schema_version"]),
886
+ str(plan["action"]),
887
+ )
888
+ table.add_row(
889
+ "Database",
890
+ "present" if plan["database_exists"] else "missing",
891
+ str(plan["database_path"] or "not sqlite"),
892
+ "backup" if plan["backup_required"] else "none",
893
+ )
894
+ console.print(table)
895
+ console.print(f"Can apply: [bold]{'yes' if plan['can_apply'] else 'no'}[/bold]")
896
+ console.print(f"Risk summary: {plan['risk_summary']}")
897
+ console.print(f"Major-version behavior: {plan['major_version_behavior']}")
898
+
899
+
900
+ @config_app.command("show")
901
+ def config_show(
902
+ reveal_secrets: Annotated[
903
+ bool,
904
+ typer.Option("--reveal-secrets", help="Show secret values instead of masking them."),
905
+ ] = False,
906
+ ) -> None:
907
+ """Show local workspace configuration."""
908
+ settings = get_settings()
909
+ paths = ensure_workspace(settings.company_discovery_home)
910
+ data = merge_dicts(read_toml(paths.config_file), read_toml(paths.secrets_file))
911
+ console.print_json(data=data if reveal_secrets else _mask_secrets(data))
912
+
913
+
914
+ @config_app.command("set")
915
+ def config_set(key: str, value: str) -> None:
916
+ """Set a non-secret local configuration value, such as llm.model."""
917
+ settings = get_settings()
918
+ target = update_config_value(settings.company_discovery_home, key, value, secret=False)
919
+ get_settings.cache_clear()
920
+ console.print(f"Updated [bold]{key}[/bold] in [bold]{target}[/bold].")
921
+
922
+
923
+ @config_app.command("set-secret")
924
+ def config_set_secret(
925
+ key: str,
926
+ value: Annotated[str | None, typer.Option("--value", help="Secret value. Prompts if omitted.")] = None,
927
+ ) -> None:
928
+ """Set a secret local configuration value, such as llm.api_key."""
929
+ settings = get_settings()
930
+ secret_value = value if value is not None else _prompt_masked_secret(f"Value for {key}", required=True)
931
+ target = update_config_value(settings.company_discovery_home, key, secret_value, secret=True)
932
+ get_settings.cache_clear()
933
+ console.print(f"Updated secret [bold]{key}[/bold] in [bold]{target}[/bold].")
934
+
935
+
936
+ @skills_app.command("list-targets")
937
+ def skills_list_targets(
938
+ json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
939
+ ) -> None:
940
+ """List supported agent skill installation targets."""
941
+ targets = [
942
+ {
943
+ "target": target.key,
944
+ "label": target.label,
945
+ "path": str(target.root),
946
+ "detected": target.detected,
947
+ }
948
+ for target in detect_targets()
949
+ ]
950
+ if json_output:
951
+ console.print_json(data={"targets": targets})
952
+ return
953
+ table = Table(title="Skill targets", show_header=True, header_style="bold")
954
+ table.add_column("Target")
955
+ table.add_column("Detected")
956
+ table.add_column("Path")
957
+ for target in targets:
958
+ table.add_row(
959
+ f"{target['label']} ({target['target']})",
960
+ "yes" if target["detected"] else "no",
961
+ target["path"],
962
+ )
963
+ console.print(table)
964
+
965
+
966
+ @skills_app.command("status")
967
+ def skills_status(
968
+ json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
969
+ ) -> None:
970
+ """Show bundled skill and per-target install status."""
971
+ settings = get_settings()
972
+ status = skill_status(settings.company_discovery_home)
973
+ if json_output:
974
+ console.print_json(data=status)
975
+ return
976
+ table = Table(title="Skill install status", show_header=True, header_style="bold")
977
+ table.add_column("Target")
978
+ table.add_column("Detected")
979
+ table.add_column("Installed")
980
+ table.add_column("Path")
981
+ for target in status["targets"]:
982
+ table.add_row(
983
+ f"{target['label']} ({target['target']})",
984
+ "yes" if target["detected"] else "no",
985
+ "yes" if target["installed"] else "no",
986
+ target["path"],
987
+ )
988
+ console.print(f"Bundled skill version: [bold]{SKILL_BUNDLE_VERSION}[/bold]")
989
+ console.print(table)
990
+
991
+
992
+ @skills_app.command("install")
993
+ def skills_install(
994
+ targets: Annotated[
995
+ list[str],
996
+ typer.Option("--target", help="Target to install into. Repeat for multiple targets."),
997
+ ] = [],
998
+ ) -> None:
999
+ """Install bundled skills into selected agent targets."""
1000
+ settings = get_settings()
1001
+ selected = targets or [target.key for target in detect_targets() if target.detected]
1002
+ if not selected:
1003
+ console.print("[yellow]No detected skill targets. Pass --target to choose one explicitly.[/yellow]")
1004
+ return
1005
+ metadata = install_skills(settings.company_discovery_home, selected)
1006
+ console.print(
1007
+ Panel(
1008
+ "\n".join(
1009
+ f"{install['label']}: {install['path']}"
1010
+ for install in metadata["installs"]
1011
+ if install["target"] in selected
1012
+ ),
1013
+ title="Skills installed",
1014
+ border_style="bright_green",
1015
+ )
1016
+ )
1017
+
1018
+
1019
+ @skills_app.command("reinstall")
1020
+ def skills_reinstall() -> None:
1021
+ """Reinstall skills into previously installed targets."""
1022
+ settings = get_settings()
1023
+ selected = installed_target_keys(settings.company_discovery_home)
1024
+ if not selected:
1025
+ console.print("[yellow]No previous skill installs found. Use leads skills install first.[/yellow]")
1026
+ return
1027
+ metadata = install_skills(settings.company_discovery_home, selected)
1028
+ console.print(
1029
+ Panel(
1030
+ "\n".join(
1031
+ f"{install['label']}: {install['path']}"
1032
+ for install in metadata["installs"]
1033
+ if install["target"] in selected
1034
+ ),
1035
+ title="Skills reinstalled",
1036
+ border_style="bright_green",
1037
+ )
1038
+ )
1039
+
1040
+
1041
+ @app.command("init")
1042
+ def init(
1043
+ workspace: Annotated[Path | None, typer.Option("--workspace", help="Workspace root.")] = None,
1044
+ llm_provider: Annotated[str, typer.Option("--llm-provider")] = "openai",
1045
+ llm_model: Annotated[str, typer.Option("--llm-model")] = "gpt-5-mini",
1046
+ llm_api_key: Annotated[str | None, typer.Option("--llm-api-key")] = None,
1047
+ exa_api_key: Annotated[str | None, typer.Option("--exa-api-key")] = None,
1048
+ apollo_api_key: Annotated[str | None, typer.Option("--apollo-api-key")] = None,
1049
+ apollo_webhook_url: Annotated[str | None, typer.Option("--apollo-webhook-url")] = None,
1050
+ targets: Annotated[
1051
+ list[str],
1052
+ typer.Option("--target", help="Skill target to install. Repeat for multiple targets."),
1053
+ ] = [],
1054
+ skip_skills: Annotated[bool, typer.Option("--skip-skills", help="Do not install agent skills.")] = False,
1055
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults for non-secret prompts.")] = False,
1056
+ ) -> None:
1057
+ """Create or repair the local leads workspace and first-run configuration."""
1058
+ env_workspace = os.getenv("LEADS_HOME") or os.getenv("COMPANY_DISCOVERY_HOME")
1059
+ if workspace:
1060
+ root = workspace.expanduser()
1061
+ elif env_workspace:
1062
+ root = Path(env_workspace).expanduser()
1063
+ else:
1064
+ root = default_workspace_root()
1065
+ if workspace is None and not yes:
1066
+ console.print(
1067
+ Panel(
1068
+ "This wizard will create one local workspace, configure your model provider, "
1069
+ "set optional data providers, install agent skills, and initialize the local database.",
1070
+ title="Welcome to leads",
1071
+ border_style="bright_cyan",
1072
+ )
1073
+ )
1074
+ root = _select_workspace_root(root)
1075
+
1076
+ paths = ensure_workspace(root)
1077
+ configure_workspace_logging(root)
1078
+ write_workspace_pointer(root)
1079
+ existing_config = read_toml(paths.config_file)
1080
+ existing_secrets = read_toml(paths.secrets_file)
1081
+
1082
+ selected_provider = llm_provider
1083
+ selected_model = llm_model
1084
+ selected_base_url = _provider_base_url(
1085
+ selected_provider,
1086
+ str(_nested_value(existing_config, "llm.base_url", "")) or None,
1087
+ )
1088
+ selected_llm_key = llm_api_key
1089
+ selected_exa_key = exa_api_key
1090
+ selected_apollo_key = apollo_api_key
1091
+ selected_apollo_webhook = apollo_webhook_url
1092
+ enable_exa = bool(exa_api_key)
1093
+ enable_apollo = bool(apollo_api_key or apollo_webhook_url)
1094
+
1095
+ if not yes:
1096
+ console.print("\n[bold bright_cyan]Model provider[/bold bright_cyan]")
1097
+ current_provider = str(_nested_value(existing_config, "llm.provider", selected_provider))
1098
+ selected_provider = _select_choice(
1099
+ "LLM provider",
1100
+ LLM_PROVIDER_CHOICES,
1101
+ default=current_provider if _provider_choice(current_provider) else "openai",
1102
+ )
1103
+ selected_base_url = _provider_base_url(
1104
+ selected_provider,
1105
+ str(_nested_value(existing_config, "llm.base_url", selected_base_url)),
1106
+ )
1107
+ if selected_provider == "custom":
1108
+ selected_base_url = typer.prompt("LLM base URL", default=selected_base_url)
1109
+ current_model = str(_nested_value(existing_config, "llm.model", selected_model))
1110
+ selected_model = _select_model(selected_provider, current_model)
1111
+ selected_llm_key = _prompt_secret(
1112
+ "LLM API key",
1113
+ existing=bool(_nested_value(existing_secrets, "llm.api_key", "")),
1114
+ required=True,
1115
+ )
1116
+
1117
+ console.print("\n[bold bright_cyan]Search provider[/bold bright_cyan]")
1118
+ enable_exa = typer.confirm("Configure Exa for live web/company search?", default=bool(exa_api_key))
1119
+ if enable_exa:
1120
+ selected_exa_key = _prompt_secret(
1121
+ "Exa API key",
1122
+ existing=bool(_nested_value(existing_secrets, "providers.exa.api_key", "")),
1123
+ )
1124
+
1125
+ console.print("\n[bold bright_cyan]Contact enrichment[/bold bright_cyan]")
1126
+ enable_apollo = typer.confirm(
1127
+ "Configure Apollo for contact email/phone enrichment?",
1128
+ default=bool(apollo_api_key or apollo_webhook_url),
1129
+ )
1130
+ if enable_apollo:
1131
+ selected_apollo_key = _prompt_secret(
1132
+ "Apollo API key",
1133
+ existing=bool(_nested_value(existing_secrets, "providers.apollo.api_key", "")),
1134
+ )
1135
+ selected_apollo_webhook = typer.prompt(
1136
+ "Apollo webhook URL for phone enrichment (blank for email-only)",
1137
+ default=str(_nested_value(existing_config, "providers.apollo.webhook_url", "")),
1138
+ show_default=False,
1139
+ ) or None
1140
+
1141
+ update_config_value(root, "llm.provider", selected_provider, secret=False)
1142
+ update_config_value(root, "llm.base_url", selected_base_url, secret=False)
1143
+ update_config_value(root, "llm.model", selected_model, secret=False)
1144
+ if selected_llm_key:
1145
+ update_config_value(root, "llm.api_key", selected_llm_key, secret=True)
1146
+ update_config_value(root, "providers.exa.enabled", str(enable_exa).lower(), secret=False)
1147
+ if selected_exa_key:
1148
+ update_config_value(root, "providers.exa.api_key", selected_exa_key, secret=True)
1149
+ update_config_value(root, "providers.apollo.enabled", str(enable_apollo).lower(), secret=False)
1150
+ if selected_apollo_key:
1151
+ update_config_value(root, "providers.apollo.api_key", selected_apollo_key, secret=True)
1152
+ if selected_apollo_webhook is not None:
1153
+ update_config_value(root, "providers.apollo.webhook_url", selected_apollo_webhook, secret=False)
1154
+
1155
+ database = Database(f"sqlite:///{paths.database_file.resolve()}")
1156
+ try:
1157
+ database.create_schema()
1158
+ finally:
1159
+ database.dispose()
1160
+
1161
+ installed: list[dict[str, object]] = []
1162
+ if not skip_skills:
1163
+ detected_targets = detect_targets()
1164
+ detected_keys = [target.key for target in detected_targets if target.detected]
1165
+ selected_targets = targets or (detected_keys if yes else [])
1166
+ if not yes:
1167
+ console.print("\n[bold bright_cyan]Agent skills[/bold bright_cyan]")
1168
+ selected_targets = _select_skill_targets(detected_targets, detected_keys)
1169
+ if selected_targets:
1170
+ installed = install_skills(root, selected_targets)["installs"]
1171
+
1172
+ status_table = Table(show_header=False, box=None, padding=(0, 1))
1173
+ status_table.add_column("Item", style="bold")
1174
+ status_table.add_column("Value")
1175
+ status_table.add_row("Workspace", str(paths.root))
1176
+ status_table.add_row("Database", str(paths.database_file))
1177
+ status_table.add_row("LLM", f"{selected_provider} / {selected_model}")
1178
+ status_table.add_row("Exa", "configured" if enable_exa else "skipped")
1179
+ status_table.add_row("Apollo", "configured" if enable_apollo else "skipped")
1180
+ status_table.add_row(
1181
+ "Skills",
1182
+ ", ".join(str(install["label"]) for install in installed) if installed else "none",
1183
+ )
1184
+
1185
+ handoff = [
1186
+ "Setup complete.",
1187
+ ]
1188
+ handoff.extend(
1189
+ [
1190
+ "",
1191
+ "Now use one of those agents to find the best leads with this system.",
1192
+ "",
1193
+ "Suggested test prompt:",
1194
+ '"Use the company search spec writer and company discovery operator to create a small '
1195
+ 'test spec for 10 US companies in a niche I choose, run it, and summarize the selected leads."',
1196
+ ]
1197
+ )
1198
+ console.print("\n")
1199
+ console.print(Panel(status_table, title="Configuration summary", border_style="bright_green"))
1200
+ console.print(Panel("\n".join(handoff), title="leads init", border_style="bright_green"))
1201
+ get_settings.cache_clear()
1202
+
1203
+
1204
+ @app.command("init-db")
1205
+ def init_db() -> None:
1206
+ """Create the database schema, optionally resetting an existing database."""
1207
+ settings = get_settings()
1208
+ database_path = settings.sqlite_database_path
1209
+ if database_path is None:
1210
+ console.print(
1211
+ "[bold red]Cannot initialize database:[/bold red] "
1212
+ "init-db requires an on-disk SQLite DATABASE_URL."
1213
+ )
1214
+ raise typer.Exit(2)
1215
+
1216
+ resetting = database_path.exists()
1217
+ if resetting and not typer.confirm(
1218
+ f"{database_path} already exists. Reset it and archive the current runs?",
1219
+ default=False,
1220
+ ):
1221
+ console.print("Database reset cancelled; nothing was changed.")
1222
+ return
1223
+
1224
+ archived_runs: Path | None = None
1225
+ if resetting and settings.artifacts_dir.exists():
1226
+ archived_runs = _next_runs_archive_path(settings.company_discovery_home)
1227
+ settings.artifacts_dir.rename(archived_runs)
1228
+
1229
+ if resetting:
1230
+ database_path.unlink()
1231
+ for suffix in ("-wal", "-shm"):
1232
+ database_path.with_name(f"{database_path.name}{suffix}").unlink(missing_ok=True)
1233
+
1234
+ settings.prepare_directories()
1235
+ database = Database(settings.resolved_database_url)
1236
+ try:
1237
+ database.create_schema()
1238
+ finally:
1239
+ database.dispose()
1240
+
1241
+ message = f"Created a fresh database at [bold]{database_path}[/bold]."
1242
+ if archived_runs is not None:
1243
+ message += f"\nArchived previous run artifacts to [bold]{archived_runs}[/bold]."
1244
+ console.print(Panel(message, title="Database initialized", border_style="bright_green"))
1245
+
1246
+
1247
+ @companies.command("discover")
1248
+ def discover(
1249
+ spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
1250
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
1251
+ ) -> None:
1252
+ """Discover companies from a validated JSON search spec."""
1253
+ try:
1254
+ spec = CompanySearchSpec.from_file(spec_path)
1255
+ except (ValueError, ValidationError) as exc:
1256
+ console.print(f"[bold red]Invalid search spec:[/bold red] {exc}")
1257
+ raise typer.Exit(2) from exc
1258
+
1259
+ settings = get_settings()
1260
+ database, _, pipeline, resources = build_runtime(settings)
1261
+ try:
1262
+ result = pipeline.discover(
1263
+ spec,
1264
+ source_spec_path=spec_path,
1265
+ progress=RichProgressReporter(verbose=verbose),
1266
+ )
1267
+ except Exception as exc:
1268
+ console.print(f"[bold red]Discovery failed:[/bold red] {exc}")
1269
+ raise typer.Exit(1) from exc
1270
+ finally:
1271
+ close_runtime(database, resources)
1272
+
1273
+ console.print(
1274
+ Panel(
1275
+ f"Run [bold]{result.run_id}[/bold]\n"
1276
+ f"Selected {result.summary.selected} | Reserve {result.summary.reserve} | "
1277
+ f"Rejected {result.summary.rejected}\n"
1278
+ f"Summary: {result.artifact_paths['summary']}",
1279
+ title="Discovery complete",
1280
+ border_style="bright_green",
1281
+ )
1282
+ )
1283
+ @companies.command("enrich")
1284
+ def enrich(
1285
+ discovery_run_id: str,
1286
+ bucket: Annotated[str, typer.Option("--bucket")] = "selected",
1287
+ limit: Annotated[int | None, typer.Option("--limit", min=1)] = None,
1288
+ refresh: Annotated[
1289
+ str,
1290
+ typer.Option(help="Refresh scope: none, contact, independence, or all."),
1291
+ ] = "none",
1292
+ allow_unknown_independence: Annotated[
1293
+ bool,
1294
+ typer.Option(
1295
+ "--allow-unknown-independence",
1296
+ help="Allow complete profiles with unknown independence into enriched.csv.",
1297
+ ),
1298
+ ] = False,
1299
+ ) -> None:
1300
+ """Enrich companies selected by a completed discovery run."""
1301
+ if bucket not in {"selected", "reserve"}:
1302
+ console.print("[bold red]Invalid bucket:[/bold red] use selected or reserve")
1303
+ raise typer.Exit(2)
1304
+ if refresh not in {"none", "contact", "independence", "all"}:
1305
+ console.print(
1306
+ "[bold red]Invalid refresh scope:[/bold red] use none, contact, independence, or all"
1307
+ )
1308
+ raise typer.Exit(2)
1309
+ _execute_enrichment(
1310
+ discovery_run_id,
1311
+ EnrichmentOptions(
1312
+ bucket=bucket,
1313
+ limit=limit,
1314
+ refresh=refresh,
1315
+ allow_unknown_independence=allow_unknown_independence,
1316
+ ),
1317
+ )
1318
+
1319
+
1320
+ def _execute_enrichment(discovery_run_id: str, options: EnrichmentOptions) -> None:
1321
+ settings = get_settings()
1322
+ database, _, pipeline, resources = build_enrichment_runtime(settings)
1323
+ try:
1324
+ result = pipeline.enrich(
1325
+ discovery_run_id,
1326
+ options=options,
1327
+ progress=RichEnrichmentProgressReporter(),
1328
+ )
1329
+ except Exception as exc:
1330
+ console.print(f"[bold red]Enrichment failed:[/bold red] {exc}")
1331
+ raise typer.Exit(1) from exc
1332
+ finally:
1333
+ close_runtime(database, resources)
1334
+ console.print(
1335
+ Panel(
1336
+ f"Run [bold]{result.run_id}[/bold]\n"
1337
+ f"Ready {result.summary.ready} | Review {result.summary.review} | "
1338
+ f"Blocked {result.summary.blocked} | Failed {result.summary.failed}\n"
1339
+ f"Output: {result.artifact_paths['enriched']}",
1340
+ title="Enrichment complete",
1341
+ border_style="bright_green",
1342
+ )
1343
+ )
1344
+
1345
+
1346
+ @companies.command("show-enrichment")
1347
+ def show_enrichment(run_id: str) -> None:
1348
+ """Show counts, source run, and artifacts for an enrichment run."""
1349
+ database, repository, _, resources = build_enrichment_runtime(get_settings())
1350
+ try:
1351
+ payload = repository.get_run(run_id)
1352
+ summary = payload["summary"]
1353
+ console.print(
1354
+ Panel(
1355
+ f"Status: {payload['status']}\n"
1356
+ f"Discovery run: {payload['discovery_run_id']}\n"
1357
+ f"Input bucket: {payload['bucket']}\n"
1358
+ f"Processed: {summary.get('processed', 0)} | Ready: {summary.get('ready', 0)} | "
1359
+ f"Review: {summary.get('review', 0)} | Blocked: {summary.get('blocked', 0)}",
1360
+ title=f"Enrichment {run_id}",
1361
+ )
1362
+ )
1363
+ if payload["artifacts"]:
1364
+ console.print_json(json.dumps(payload["artifacts"], ensure_ascii=True))
1365
+ except EnrichmentRunNotFoundError as exc:
1366
+ console.print(f"[bold red]{exc}[/bold red]")
1367
+ raise typer.Exit(1) from exc
1368
+ finally:
1369
+ close_runtime(database, resources)
1370
+
1371
+
1372
+ @companies.command("inspect-enrichment")
1373
+ def inspect_enrichment(run_id: str, domain: Annotated[str, typer.Option("--domain")]) -> None:
1374
+ """Inspect one enriched company including provenance, conflicts, and trace."""
1375
+ normalized = canonical_domain(domain)
1376
+ if normalized is None:
1377
+ console.print(f"[bold red]Invalid domain:[/bold red] {domain}")
1378
+ raise typer.Exit(2)
1379
+ database, repository, _, resources = build_enrichment_runtime(get_settings())
1380
+ try:
1381
+ console.print_json(json.dumps(repository.inspect_item(run_id, normalized), ensure_ascii=True))
1382
+ except (EnrichmentRunNotFoundError, CandidateNotFoundError) as exc:
1383
+ console.print(f"[bold red]{exc}[/bold red]")
1384
+ raise typer.Exit(1) from exc
1385
+ finally:
1386
+ close_runtime(database, resources)
1387
+
1388
+
1389
+ @companies.command("export-enrichment")
1390
+ def export_enrichment(run_id: str) -> None:
1391
+ """Regenerate enrichment CSV, Markdown, and JSON artifacts."""
1392
+ settings = get_settings()
1393
+ database, repository, _, resources = build_enrichment_runtime(settings)
1394
+ try:
1395
+ payload = repository.get_run(run_id)
1396
+ if payload["status"] != "completed":
1397
+ raise ValueError(f"enrichment run {run_id} is {payload['status']}, not completed")
1398
+ summary = EnrichmentSummary.model_validate(payload["summary"])
1399
+ paths = EnrichmentArtifactExporter(settings.artifacts_dir).export(payload, summary)
1400
+ repository.set_artifacts(run_id, paths)
1401
+ console.print(f"Exported enrichment [bold]{run_id}[/bold] to {Path(paths['json']).parent}")
1402
+ except (EnrichmentRunNotFoundError, ValueError) as exc:
1403
+ console.print(f"[bold red]{exc}[/bold red]")
1404
+ raise typer.Exit(1) from exc
1405
+ finally:
1406
+ close_runtime(database, resources)
1407
+
1408
+
1409
+ @companies.command("validate-spec")
1410
+ def validate_spec(
1411
+ spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
1412
+ ) -> None:
1413
+ """Validate and print the normalized form of a search spec without running discovery."""
1414
+ try:
1415
+ spec = CompanySearchSpec.from_file(spec_path)
1416
+ except (ValueError, ValidationError) as exc:
1417
+ console.print(f"[bold red]Invalid search spec:[/bold red] {exc}")
1418
+ raise typer.Exit(2) from exc
1419
+ console.print("[bold green]Valid company search spec[/bold green]")
1420
+ console.print_json(spec.model_dump_json(indent=2))
1421
+ for condition in spec.missing_constraints:
1422
+ console.print(f"[yellow]Note:[/yellow] {condition}")
1423
+
1424
+
1425
+ @companies.command("show-run")
1426
+ def show_run(run_id: str) -> None:
1427
+ """Show the spec, queries, counts, and artifacts for a prior run."""
1428
+ database, repository, _, resources = build_runtime(get_settings())
1429
+ try:
1430
+ payload = repository.get_run(run_id)
1431
+ _render_run(payload)
1432
+ except RunNotFoundError as exc:
1433
+ console.print(f"[bold red]{exc}[/bold red]")
1434
+ raise typer.Exit(1) from exc
1435
+ finally:
1436
+ close_runtime(database, resources)
1437
+
1438
+
1439
+ @companies.command("export")
1440
+ def export_run(run_id: str) -> None:
1441
+ """Regenerate CSV, Markdown, and JSON artifacts for a prior run."""
1442
+ settings = get_settings()
1443
+ database, repository, _, resources = build_runtime(settings)
1444
+ try:
1445
+ payload = repository.get_run(run_id)
1446
+ if payload["status"] != "completed":
1447
+ raise ValueError(f"run {run_id} is {payload['status']}, not completed")
1448
+ summary = RunSummary.model_validate(payload["summary"])
1449
+ paths = ArtifactExporter(settings.artifacts_dir).export(payload, summary)
1450
+ repository.set_artifacts(run_id, paths)
1451
+ console.print(f"Exported run [bold]{run_id}[/bold] to {Path(paths['summary']).parent}")
1452
+ except (RunNotFoundError, ValueError) as exc:
1453
+ console.print(f"[bold red]{exc}[/bold red]")
1454
+ raise typer.Exit(1) from exc
1455
+ finally:
1456
+ close_runtime(database, resources)
1457
+
1458
+
1459
+ @companies.command("inspect")
1460
+ def inspect(run_id: str, domain: Annotated[str, typer.Option("--domain")]) -> None:
1461
+ """Inspect one run candidate, its evidence, and its evaluation."""
1462
+ normalized = canonical_domain(domain)
1463
+ if normalized is None:
1464
+ console.print(f"[bold red]Invalid domain:[/bold red] {domain}")
1465
+ raise typer.Exit(2)
1466
+ database, repository, _, resources = build_runtime(get_settings())
1467
+ try:
1468
+ payload = repository.inspect_candidate(run_id, normalized)
1469
+ console.print_json(json.dumps(payload, ensure_ascii=True))
1470
+ except CandidateNotFoundError as exc:
1471
+ console.print(f"[bold red]{exc}[/bold red]")
1472
+ raise typer.Exit(1) from exc
1473
+ finally:
1474
+ close_runtime(database, resources)
1475
+
1476
+
1477
+ @companies.command("rerun")
1478
+ def rerun(
1479
+ run_id: str,
1480
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
1481
+ ) -> None:
1482
+ """Run a prior immutable spec again with its saved novelty policy."""
1483
+ settings = get_settings()
1484
+ database, repository, pipeline, resources = build_runtime(settings)
1485
+ try:
1486
+ prior = repository.get_run(run_id)
1487
+ spec = CompanySearchSpec.model_validate(prior["spec"])
1488
+ result = pipeline.discover(spec, progress=RichProgressReporter(verbose=verbose))
1489
+ console.print(
1490
+ f"Rerun complete: [bold]{result.run_id}[/bold] | selected {result.summary.selected} | "
1491
+ f"reserve {result.summary.reserve} | rejected {result.summary.rejected}"
1492
+ )
1493
+ except RunNotFoundError as exc:
1494
+ console.print(f"[bold red]{exc}[/bold red]")
1495
+ raise typer.Exit(1) from exc
1496
+ except Exception as exc:
1497
+ console.print(f"[bold red]Rerun failed:[/bold red] {exc}")
1498
+ raise typer.Exit(1) from exc
1499
+ finally:
1500
+ close_runtime(database, resources)
1501
+
1502
+
1503
+ def _render_run(payload: dict[str, object]) -> None:
1504
+ spec = payload["spec"]
1505
+ assert isinstance(spec, dict)
1506
+ summary = payload["summary"]
1507
+ assert isinstance(summary, dict)
1508
+ verticals = spec["verticals"]
1509
+ geography = spec["geography"]
1510
+ assert isinstance(verticals, list) and isinstance(geography, dict)
1511
+ vertical_labels = ", ".join(
1512
+ str(vertical["label"]) for vertical in verticals if isinstance(vertical, dict)
1513
+ )
1514
+ console.print(
1515
+ Panel(
1516
+ f"Status: {payload['status']}\n"
1517
+ f"Verticals: {vertical_labels}\n"
1518
+ f"Balance: {spec.get('balance_mode', 'soft')}\n"
1519
+ f"Novelty: {spec.get('novelty_mode', 'unused_memory')}\n"
1520
+ f"Geography: {geography['country']} / {', '.join(geography['states']) or 'all'}\n"
1521
+ f"Requested: {spec['count']}",
1522
+ title=f"Run {payload['run_id']}",
1523
+ )
1524
+ )
1525
+ table = Table("Metric", "Count")
1526
+ for key in ("memory_matched", "memory_reused", "external_gap", "raw_results", "selected", "reserve", "rejected"):
1527
+ table.add_row(key.replace("_", " ").title(), str(summary.get(key, 0)))
1528
+ console.print(table)
1529
+ queries = payload["queries"]
1530
+ assert isinstance(queries, list)
1531
+ if queries:
1532
+ console.print("[bold]Queries[/bold]")
1533
+ for query in queries:
1534
+ console.print(f" * {query}")
1535
+ artifacts = payload["artifacts"]
1536
+ assert isinstance(artifacts, dict)
1537
+ if artifacts:
1538
+ console.print("[bold]Artifacts[/bold]")
1539
+ for name, path in artifacts.items():
1540
+ console.print(f" {name}: {path}")
1541
+
1542
+
1543
+ @contacts.command("validate-spec")
1544
+ def validate_contact_spec(
1545
+ spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
1546
+ ) -> None:
1547
+ """Validate and print a normalized contact discovery spec."""
1548
+ try:
1549
+ spec = ContactSearchSpec.from_file(spec_path)
1550
+ except (ValueError, ValidationError) as exc:
1551
+ console.print(f"[bold red]Invalid contact spec:[/bold red] {exc}")
1552
+ raise typer.Exit(2) from exc
1553
+ console.print("[bold green]Valid contact search spec[/bold green]")
1554
+ console.print_json(spec.model_dump_json(indent=2))
1555
+
1556
+
1557
+ @contacts.command("discover")
1558
+ def discover_contacts(
1559
+ spec_path: Annotated[Path, typer.Option("--spec", exists=True, dir_okay=False, readable=True)],
1560
+ ) -> None:
1561
+ """Discover current role-matched people at enriched companies."""
1562
+ try:
1563
+ spec = ContactSearchSpec.from_file(spec_path)
1564
+ except (ValueError, ValidationError) as exc:
1565
+ console.print(f"[bold red]Invalid contact spec:[/bold red] {exc}")
1566
+ raise typer.Exit(2) from exc
1567
+
1568
+ database, _, pipeline, resources = build_contact_runtime(get_settings())
1569
+ try:
1570
+ result = pipeline.discover(
1571
+ spec,
1572
+ source_spec_path=spec_path,
1573
+ progress=RichContactProgressReporter(),
1574
+ )
1575
+ except Exception as exc:
1576
+ console.print(f"[bold red]Contact discovery failed:[/bold red] {exc}")
1577
+ raise typer.Exit(1) from exc
1578
+ finally:
1579
+ close_runtime(database, resources)
1580
+
1581
+ console.print(
1582
+ Panel(
1583
+ f"Run [bold]{result.run_id}[/bold]\n"
1584
+ f"Accepted {result.summary.accepted} | Review {result.summary.review} | "
1585
+ f"Rejected {result.summary.rejected}\n"
1586
+ f"Output: {result.artifact_paths['accepted']}",
1587
+ title="Contact discovery complete",
1588
+ border_style="bright_green",
1589
+ )
1590
+ )
1591
+
1592
+
1593
+ @contacts.command("show-run")
1594
+ def show_contact_run(run_id: str) -> None:
1595
+ """Show scope, counts, queries, and artifacts for a contact discovery run."""
1596
+ database, repository, _, resources = build_contact_runtime(get_settings())
1597
+ try:
1598
+ payload = repository.get_run(run_id)
1599
+ summary = payload["summary"]
1600
+ console.print(
1601
+ Panel(
1602
+ f"Status: {payload['status']}\n"
1603
+ f"Company enrichment run: {payload['source_enrichment_run_id']}\n"
1604
+ f"Companies: {summary.get('companies_loaded', 0)} | "
1605
+ f"Memory: {summary.get('memory_reused', 0)} | "
1606
+ f"Queries: {summary.get('queries_run', 0)}\n"
1607
+ f"Accepted: {summary.get('accepted', 0)} | "
1608
+ f"Review: {summary.get('review', 0)} | "
1609
+ f"Rejected: {summary.get('rejected', 0)}",
1610
+ title=f"Contact run {run_id}",
1611
+ )
1612
+ )
1613
+ if payload["artifacts"]:
1614
+ console.print_json(json.dumps(payload["artifacts"], ensure_ascii=True))
1615
+ except ContactRunNotFoundError as exc:
1616
+ console.print(f"[bold red]{exc}[/bold red]")
1617
+ raise typer.Exit(1) from exc
1618
+ finally:
1619
+ close_runtime(database, resources)
1620
+
1621
+
1622
+ @contacts.command("inspect")
1623
+ def inspect_contact(
1624
+ run_id: str,
1625
+ person: Annotated[str, typer.Option("--person")],
1626
+ ) -> None:
1627
+ """Inspect one person's role decisions and live evidence."""
1628
+ database, repository, _, resources = build_contact_runtime(get_settings())
1629
+ try:
1630
+ console.print_json(
1631
+ json.dumps(repository.inspect_contact(run_id, person), ensure_ascii=True)
1632
+ )
1633
+ except (ContactRunNotFoundError, ContactNotFoundError) as exc:
1634
+ console.print(f"[bold red]{exc}[/bold red]")
1635
+ raise typer.Exit(1) from exc
1636
+ finally:
1637
+ close_runtime(database, resources)
1638
+
1639
+
1640
+ @contacts.command("export")
1641
+ def export_contact_run(run_id: str) -> None:
1642
+ """Regenerate contact discovery artifacts from the stored run."""
1643
+ settings = get_settings()
1644
+ database, repository, _, resources = build_contact_runtime(settings)
1645
+ try:
1646
+ payload = repository.get_run(run_id)
1647
+ if payload["status"] != "completed":
1648
+ raise ValueError(f"contact run {run_id} is {payload['status']}, not completed")
1649
+ summary = ContactDiscoverySummary.model_validate(payload["summary"])
1650
+ paths = ContactDiscoveryArtifactExporter(settings.artifacts_dir).export(payload, summary)
1651
+ repository.set_artifacts(run_id, paths)
1652
+ console.print(f"Exported contact run [bold]{run_id}[/bold] to {Path(paths['json']).parent}")
1653
+ except (ContactRunNotFoundError, ValueError) as exc:
1654
+ console.print(f"[bold red]{exc}[/bold red]")
1655
+ raise typer.Exit(1) from exc
1656
+ finally:
1657
+ close_runtime(database, resources)
1658
+
1659
+
1660
+ @contacts.command("enrich")
1661
+ def enrich_contacts(
1662
+ contact_discovery_run_id: str,
1663
+ email: Annotated[
1664
+ bool,
1665
+ typer.Option("--email/--no-email", help="Request Apollo email enrichment."),
1666
+ ] = True,
1667
+ phone: Annotated[
1668
+ bool,
1669
+ typer.Option("--phone/--no-phone", help="Request Apollo phone enrichment."),
1670
+ ] = True,
1671
+ refresh: Annotated[
1672
+ bool,
1673
+ typer.Option("--refresh", help="Ignore fresh Apollo memory and query again."),
1674
+ ] = False,
1675
+ ) -> None:
1676
+ """Enrich accepted discovered contacts with Apollo email and phone data."""
1677
+ if not email and not phone:
1678
+ console.print("[bold red]Enable at least one of --email or --phone.[/bold red]")
1679
+ raise typer.Exit(2)
1680
+ database: Database | None = None
1681
+ resources: list[object] = []
1682
+ try:
1683
+ database, _, pipeline, resources = build_contact_enrichment_runtime(get_settings())
1684
+ result = pipeline.enrich(
1685
+ contact_discovery_run_id,
1686
+ options=ContactEnrichmentOptions(
1687
+ reveal_email=email,
1688
+ reveal_phone=phone,
1689
+ refresh=refresh,
1690
+ ),
1691
+ progress=RichContactEnrichmentProgressReporter(),
1692
+ )
1693
+ except Exception as exc:
1694
+ console.print(f"[bold red]Contact enrichment failed:[/bold red] {exc}")
1695
+ raise typer.Exit(1) from exc
1696
+ finally:
1697
+ if database is not None:
1698
+ close_runtime(database, resources)
1699
+
1700
+ console.print(
1701
+ Panel(
1702
+ f"Run [bold]{result.run_id}[/bold]\n"
1703
+ f"Ready {result.summary.ready} | Review {result.summary.review} | "
1704
+ f"Blocked {result.summary.blocked}\n"
1705
+ f"Output: {result.artifact_paths['ready']}",
1706
+ title="Contact enrichment complete",
1707
+ border_style="bright_green",
1708
+ )
1709
+ )
1710
+
1711
+
1712
+ @contacts.command("show-enrichment")
1713
+ def show_contact_enrichment(run_id: str) -> None:
1714
+ """Show counts and artifacts for an Apollo contact enrichment run."""
1715
+ database: Database | None = None
1716
+ try:
1717
+ database, repository = build_contact_enrichment_repository(get_settings())
1718
+ payload = repository.get_run(run_id)
1719
+ summary = payload["summary"]
1720
+ console.print(
1721
+ Panel(
1722
+ f"Status: {payload['status']}\n"
1723
+ f"Contact discovery run: {payload['source_contact_run_id']}\n"
1724
+ f"Contacts: {summary.get('contacts_loaded', 0)} | "
1725
+ f"Memory: {summary.get('memory_reused', 0)} | "
1726
+ f"Apollo requests: {summary.get('apollo_requests', 0)}\n"
1727
+ f"Ready: {summary.get('ready', 0)} | "
1728
+ f"Review: {summary.get('review', 0)} | "
1729
+ f"Blocked: {summary.get('blocked', 0)}",
1730
+ title=f"Contact enrichment {run_id}",
1731
+ )
1732
+ )
1733
+ if payload["artifacts"]:
1734
+ console.print_json(json.dumps(payload["artifacts"], ensure_ascii=True))
1735
+ except (ContactEnrichmentRunNotFoundError, ValueError) as exc:
1736
+ console.print(f"[bold red]{exc}[/bold red]")
1737
+ raise typer.Exit(1) from exc
1738
+ finally:
1739
+ if database is not None:
1740
+ database.dispose()
1741
+
1742
+
1743
+ @contacts.command("inspect-enrichment")
1744
+ def inspect_contact_enrichment(
1745
+ run_id: str,
1746
+ person: Annotated[str, typer.Option("--person")],
1747
+ ) -> None:
1748
+ """Inspect Apollo fields, trust checks, and trace for one enriched person."""
1749
+ database: Database | None = None
1750
+ try:
1751
+ database, repository = build_contact_enrichment_repository(get_settings())
1752
+ console.print_json(
1753
+ json.dumps(repository.inspect_contact(run_id, person), ensure_ascii=True)
1754
+ )
1755
+ except (ContactEnrichmentRunNotFoundError, LookupError) as exc:
1756
+ console.print(f"[bold red]{exc}[/bold red]")
1757
+ raise typer.Exit(1) from exc
1758
+ finally:
1759
+ if database is not None:
1760
+ database.dispose()
1761
+
1762
+
1763
+ @contacts.command("export-enrichment")
1764
+ def export_contact_enrichment(run_id: str) -> None:
1765
+ """Regenerate Apollo contact enrichment artifacts from the stored run."""
1766
+ settings = get_settings()
1767
+ database: Database | None = None
1768
+ try:
1769
+ database, repository = build_contact_enrichment_repository(settings)
1770
+ payload = repository.get_run(run_id)
1771
+ if payload["status"] != "completed":
1772
+ raise ValueError(f"contact enrichment run {run_id} is {payload['status']}, not completed")
1773
+ summary = ContactEnrichmentSummary.model_validate(payload["summary"])
1774
+ paths = ContactEnrichmentArtifactExporter(settings.artifacts_dir).export(payload, summary)
1775
+ repository.set_artifacts(run_id, paths)
1776
+ console.print(
1777
+ f"Exported contact enrichment [bold]{run_id}[/bold] to "
1778
+ f"{Path(paths['json']).parent}"
1779
+ )
1780
+ except (ContactEnrichmentRunNotFoundError, ValueError) as exc:
1781
+ console.print(f"[bold red]{exc}[/bold red]")
1782
+ raise typer.Exit(1) from exc
1783
+ finally:
1784
+ if database is not None:
1785
+ database.dispose()
1786
+
1787
+
1788
+ if __name__ == "__main__":
1789
+ app()