celltype-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 (89) hide show
  1. celltype_cli-0.1.0.dist-info/METADATA +267 -0
  2. celltype_cli-0.1.0.dist-info/RECORD +89 -0
  3. celltype_cli-0.1.0.dist-info/WHEEL +4 -0
  4. celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. ct/__init__.py +3 -0
  7. ct/agent/__init__.py +0 -0
  8. ct/agent/case_studies.py +426 -0
  9. ct/agent/config.py +523 -0
  10. ct/agent/doctor.py +544 -0
  11. ct/agent/knowledge.py +523 -0
  12. ct/agent/loop.py +99 -0
  13. ct/agent/mcp_server.py +478 -0
  14. ct/agent/orchestrator.py +733 -0
  15. ct/agent/runner.py +656 -0
  16. ct/agent/sandbox.py +481 -0
  17. ct/agent/session.py +145 -0
  18. ct/agent/system_prompt.py +186 -0
  19. ct/agent/trace_store.py +228 -0
  20. ct/agent/trajectory.py +169 -0
  21. ct/agent/types.py +182 -0
  22. ct/agent/workflows.py +462 -0
  23. ct/api/__init__.py +1 -0
  24. ct/api/app.py +211 -0
  25. ct/api/config.py +120 -0
  26. ct/api/engine.py +124 -0
  27. ct/cli.py +1448 -0
  28. ct/data/__init__.py +0 -0
  29. ct/data/compute_providers.json +59 -0
  30. ct/data/cro_database.json +395 -0
  31. ct/data/downloader.py +238 -0
  32. ct/data/loaders.py +252 -0
  33. ct/kb/__init__.py +5 -0
  34. ct/kb/benchmarks.py +147 -0
  35. ct/kb/governance.py +106 -0
  36. ct/kb/ingest.py +415 -0
  37. ct/kb/reasoning.py +129 -0
  38. ct/kb/schema_monitor.py +162 -0
  39. ct/kb/substrate.py +387 -0
  40. ct/models/__init__.py +0 -0
  41. ct/models/llm.py +370 -0
  42. ct/tools/__init__.py +195 -0
  43. ct/tools/_compound_resolver.py +297 -0
  44. ct/tools/biomarker.py +368 -0
  45. ct/tools/cellxgene.py +282 -0
  46. ct/tools/chemistry.py +1371 -0
  47. ct/tools/claude.py +390 -0
  48. ct/tools/clinical.py +1153 -0
  49. ct/tools/clue.py +249 -0
  50. ct/tools/code.py +1069 -0
  51. ct/tools/combination.py +397 -0
  52. ct/tools/compute.py +402 -0
  53. ct/tools/cro.py +413 -0
  54. ct/tools/data_api.py +2114 -0
  55. ct/tools/design.py +295 -0
  56. ct/tools/dna.py +575 -0
  57. ct/tools/experiment.py +604 -0
  58. ct/tools/expression.py +655 -0
  59. ct/tools/files.py +957 -0
  60. ct/tools/genomics.py +1387 -0
  61. ct/tools/http_client.py +146 -0
  62. ct/tools/imaging.py +319 -0
  63. ct/tools/intel.py +223 -0
  64. ct/tools/literature.py +743 -0
  65. ct/tools/network.py +422 -0
  66. ct/tools/notification.py +111 -0
  67. ct/tools/omics.py +3330 -0
  68. ct/tools/ops.py +1230 -0
  69. ct/tools/parity.py +649 -0
  70. ct/tools/pk.py +245 -0
  71. ct/tools/protein.py +678 -0
  72. ct/tools/regulatory.py +643 -0
  73. ct/tools/remote_data.py +179 -0
  74. ct/tools/report.py +181 -0
  75. ct/tools/repurposing.py +376 -0
  76. ct/tools/safety.py +1280 -0
  77. ct/tools/shell.py +178 -0
  78. ct/tools/singlecell.py +533 -0
  79. ct/tools/statistics.py +552 -0
  80. ct/tools/structure.py +882 -0
  81. ct/tools/target.py +901 -0
  82. ct/tools/translational.py +123 -0
  83. ct/tools/viability.py +218 -0
  84. ct/ui/__init__.py +0 -0
  85. ct/ui/markdown.py +31 -0
  86. ct/ui/status.py +258 -0
  87. ct/ui/suggestions.py +567 -0
  88. ct/ui/terminal.py +1456 -0
  89. ct/ui/traces.py +112 -0
ct/cli.py ADDED
@@ -0,0 +1,1448 @@
1
+ """
2
+ ct CLI entry point.
3
+
4
+ Usage:
5
+ ct # Interactive mode
6
+ ct "your question" # Single query
7
+ ct --smiles "CCO" "Profile" # With compound context
8
+ ct config set key value # Configuration
9
+ ct data pull depmap # Data management
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import typer
18
+ from typing import Optional
19
+ from pathlib import Path
20
+ from datetime import datetime, timezone
21
+ from rich.console import Console
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+
25
+ from ct import __version__
26
+ from ct.agent.session import Session
27
+ from ct.ui.terminal import InteractiveTerminal
28
+
29
+
30
+ # ─── Startup banner ─────────────────────────────────────────
31
+ BANNER = """
32
+ [bold #50fa7b] ██████╗███████╗██╗ ██╗ ████████╗██╗ ██╗██████╗ ███████╗[/]
33
+ [bold #40f695]██╔════╝██╔════╝██║ ██║ ╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝[/]
34
+ [bold #30f1b0]██║ █████╗ ██║ ██║ ██║ ╚████╔╝ ██████╔╝█████╗ [/]
35
+ [bold #20edca]██║ ██╔══╝ ██║ ██║ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ [/]
36
+ [bold #10e9e4]╚██████╗███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗[/]
37
+ [bold #00e5ff] ╚═════╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝[/]
38
+ """
39
+
40
+ app = typer.Typer(
41
+ name="ct",
42
+ help=(
43
+ "CellType CLI — An autonomous agent for drug discovery research.\n\n"
44
+ "Common usage:\n"
45
+ ' ct "your research question"\n'
46
+ ' ct --smiles "CCO" "Profile this compound"\n'
47
+ " ct config show\n"
48
+ " ct tool list"
49
+ ),
50
+ no_args_is_help=False,
51
+ )
52
+ console = Console()
53
+
54
+ # ─── Config subcommand ────────────────────────────────────────
55
+
56
+ config_app = typer.Typer(help="Manage ct configuration")
57
+ app.add_typer(config_app, name="config")
58
+
59
+
60
+ @config_app.command("set")
61
+ def config_set(key: str, value: str):
62
+ """Set a configuration value."""
63
+ from ct.agent.config import Config
64
+ cfg = Config.load()
65
+ try:
66
+ cfg.set(key, value)
67
+ except ValueError as exc:
68
+ console.print(f"[red]{exc}[/red]")
69
+ raise typer.Exit(code=2)
70
+ cfg.save()
71
+ if key == "agent.profile":
72
+ console.print(
73
+ f" [green]Set[/green] {key} = {cfg.get('agent.profile')} "
74
+ "(applied preset settings)"
75
+ )
76
+ else:
77
+ console.print(f" [green]Set[/green] {key} = {value}")
78
+
79
+
80
+ @config_app.command("get")
81
+ def config_get(key: str):
82
+ """Get a configuration value."""
83
+ from ct.agent.config import Config
84
+ cfg = Config.load()
85
+ val = cfg.get(key)
86
+ console.print(f" {key} = {val}")
87
+
88
+
89
+ @config_app.command("show")
90
+ def config_show():
91
+ """Show all configuration."""
92
+ from ct.agent.config import Config
93
+ cfg = Config.load()
94
+ console.print(cfg.to_table())
95
+
96
+
97
+ @config_app.command("validate")
98
+ def config_validate():
99
+ """Validate configuration and report issues."""
100
+ from ct.agent.config import Config
101
+ cfg = Config.load()
102
+ issues = cfg.validate()
103
+ if not issues:
104
+ console.print("[green]Configuration is valid. No issues found.[/green]")
105
+ return
106
+ console.print(f"[yellow]Found {len(issues)} issue(s):[/yellow]")
107
+ for issue in issues:
108
+ console.print(f" - {issue}")
109
+ raise typer.Exit(code=2)
110
+
111
+
112
+ # ─── Keys command ────────────────────────────────────────────
113
+
114
+ @app.command("keys")
115
+ def keys_cmd():
116
+ """Show status of optional API keys and what they unlock."""
117
+ from ct.agent.config import Config
118
+ cfg = Config.load()
119
+ console.print(cfg.keys_table())
120
+
121
+
122
+ @app.command("setup")
123
+ def setup_cmd(
124
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="Anthropic API key (non-interactive mode)"),
125
+ ):
126
+ """Interactive setup wizard — configure ct for first use."""
127
+ from ct.agent.config import Config
128
+
129
+ cfg = Config.load()
130
+
131
+ console.print()
132
+ console.print(
133
+ Panel(
134
+ "[bold]Welcome to CellType[/bold]\n\n"
135
+ "This wizard will configure ct for first use.\n"
136
+ "You need an Anthropic API key to get started.",
137
+ title="[cyan]ct setup[/cyan]",
138
+ border_style="cyan",
139
+ )
140
+ )
141
+ console.print()
142
+
143
+ # Determine the key — non-interactive flag, existing config, env var, or prompt
144
+ existing_key = cfg.llm_api_key()
145
+
146
+ if api_key:
147
+ # Non-interactive mode
148
+ chosen_key = api_key
149
+ elif existing_key:
150
+ masked = existing_key[:7] + "..." + existing_key[-4:] if len(existing_key) > 11 else "***"
151
+ console.print(f" API key already configured: [green]{masked}[/green]")
152
+ try:
153
+ keep = input(" Keep existing key? [Y/n] ").strip().lower()
154
+ except (EOFError, KeyboardInterrupt):
155
+ console.print("\n [dim]Setup cancelled.[/dim]")
156
+ raise typer.Exit()
157
+ if keep in ("", "y", "yes"):
158
+ chosen_key = existing_key
159
+ console.print(" [green]Keeping existing key.[/green]")
160
+ else:
161
+ chosen_key = _prompt_api_key()
162
+ else:
163
+ # Check env var
164
+ env_key = os.environ.get("ANTHROPIC_API_KEY")
165
+ if env_key:
166
+ masked = env_key[:7] + "..." + env_key[-4:] if len(env_key) > 11 else "***"
167
+ console.print(f" Found ANTHROPIC_API_KEY in environment: [green]{masked}[/green]")
168
+ try:
169
+ save_it = input(" Save to ct config? [Y/n] ").strip().lower()
170
+ except (EOFError, KeyboardInterrupt):
171
+ console.print("\n [dim]Setup cancelled.[/dim]")
172
+ raise typer.Exit()
173
+ if save_it in ("", "y", "yes"):
174
+ chosen_key = env_key
175
+ else:
176
+ chosen_key = _prompt_api_key()
177
+ else:
178
+ chosen_key = _prompt_api_key()
179
+
180
+ # Validate key format
181
+ if not chosen_key or not chosen_key.startswith("sk-ant-"):
182
+ console.print(
183
+ "\n [yellow]Warning:[/yellow] Key doesn't start with 'sk-ant-'. "
184
+ "Anthropic API keys typically begin with 'sk-ant-api03-'."
185
+ )
186
+ try:
187
+ proceed = input(" Continue anyway? [y/N] ").strip().lower()
188
+ except (EOFError, KeyboardInterrupt):
189
+ console.print("\n [dim]Setup cancelled.[/dim]")
190
+ raise typer.Exit()
191
+ if proceed not in ("y", "yes"):
192
+ console.print(" [dim]Setup cancelled.[/dim]")
193
+ raise typer.Exit()
194
+
195
+ # Save
196
+ cfg.set("llm.api_key", chosen_key)
197
+ cfg.set("llm.provider", "anthropic")
198
+ cfg.save()
199
+ console.print("\n [green]API key saved to ~/.ct/config.json[/green]")
200
+
201
+ # Quick health check
202
+ console.print()
203
+ console.print(" [cyan]Running health check...[/cyan]")
204
+ from ct.agent.doctor import run_checks, to_table, has_errors
205
+ checks = run_checks(cfg)
206
+ console.print(to_table(checks))
207
+
208
+ if has_errors(checks):
209
+ console.print(
210
+ "\n [yellow]Some issues detected.[/yellow] Run `ct doctor` for details."
211
+ )
212
+ else:
213
+ console.print("\n [green]All checks passed.[/green]")
214
+
215
+ # Done
216
+ console.print()
217
+ console.print(
218
+ Panel(
219
+ "[bold green]You're all set![/bold green]\n\n"
220
+ " [cyan]ct[/cyan] Interactive mode\n"
221
+ ' [cyan]ct "your question"[/cyan] Single query\n'
222
+ " [cyan]ct doctor[/cyan] Full health check\n"
223
+ " [cyan]ct keys[/cyan] Optional API keys",
224
+ title="[green]Quick Start[/green]",
225
+ border_style="green",
226
+ )
227
+ )
228
+
229
+
230
+ def _prompt_api_key() -> str:
231
+ """Prompt user for API key with masked input."""
232
+ import getpass
233
+ console.print(" Get your key at: [link=https://console.anthropic.com/settings/keys]console.anthropic.com/settings/keys[/link]")
234
+ console.print()
235
+ try:
236
+ key = getpass.getpass(" Enter your Anthropic API key: ")
237
+ except (EOFError, KeyboardInterrupt):
238
+ console.print("\n [dim]Setup cancelled.[/dim]")
239
+ raise typer.Exit()
240
+ return key.strip()
241
+
242
+
243
+ @app.command("doctor")
244
+ def doctor_cmd():
245
+ """Run environment and configuration health checks."""
246
+ from ct.agent.config import Config
247
+ from ct.agent.doctor import run_checks, to_table, has_errors
248
+
249
+ cfg = Config.load()
250
+ checks = run_checks(cfg, session=Session(config=cfg, mode="batch"))
251
+ console.print(to_table(checks))
252
+
253
+ if has_errors(checks):
254
+ console.print(
255
+ "\n[red]Blocking issues found.[/red] "
256
+ "Fix errors above, then rerun `ct doctor`."
257
+ )
258
+ raise typer.Exit(code=1)
259
+
260
+ console.print("\n[green]No blocking issues found.[/green]")
261
+
262
+
263
+ # ─── Data subcommand ──────────────────────────────────────────
264
+
265
+ data_app = typer.Typer(help="Manage local datasets")
266
+ app.add_typer(data_app, name="data")
267
+
268
+
269
+ @data_app.command("pull")
270
+ def data_pull(
271
+ dataset: str = typer.Argument(help="Dataset to download (depmap, prism, msigdb, alphafold)"),
272
+ output: Optional[Path] = typer.Option(None, help="Output directory"),
273
+ ):
274
+ """Download a dataset for local use."""
275
+ from ct.data.downloader import download_dataset
276
+ download_dataset(dataset, output)
277
+
278
+
279
+ @data_app.command("status")
280
+ def data_status():
281
+ """Show status of local datasets."""
282
+ from ct.data.downloader import dataset_status
283
+ console.print(dataset_status())
284
+
285
+
286
+ # ─── Tool subcommands (direct tool access) ────────────────────
287
+
288
+ tool_app = typer.Typer(help="Run individual tools directly")
289
+ app.add_typer(tool_app, name="tool")
290
+
291
+
292
+ @tool_app.command("list")
293
+ def tool_list():
294
+ """List all available tools."""
295
+ from ct.tools import registry, ensure_loaded, tool_load_errors
296
+ ensure_loaded()
297
+ console.print(registry.list_tools_table())
298
+ errors = tool_load_errors()
299
+ if errors:
300
+ names = ", ".join(sorted(errors.keys())[:8])
301
+ extra = "" if len(errors) <= 8 else f" (+{len(errors) - 8} more)"
302
+ console.print(
303
+ f"[yellow]Warning:[/yellow] {len(errors)} tool module(s) failed to load: "
304
+ f"{names}{extra}"
305
+ )
306
+
307
+
308
+ # ─── Knowledge subcommands ────────────────────────────────────
309
+
310
+ knowledge_app = typer.Typer(help="Manage knowledge substrate, ingestion, and quality gates")
311
+ app.add_typer(knowledge_app, name="knowledge")
312
+
313
+
314
+ # ─── Trace subcommands ────────────────────────────────────────
315
+
316
+ trace_app = typer.Typer(help="Inspect and diagnose execution traces")
317
+ app.add_typer(trace_app, name="trace")
318
+
319
+
320
+ def _latest_trace_path() -> Optional[Path]:
321
+ from ct.agent.trace import TraceLogger
322
+
323
+ traces_dir = TraceLogger.traces_dir()
324
+ traces = list(traces_dir.glob("*.trace.jsonl"))
325
+ if not traces:
326
+ return None
327
+ return max(traces, key=lambda p: p.stat().st_mtime)
328
+
329
+
330
+ def _resolve_trace_path(path: Optional[Path], session_id: Optional[str]) -> Optional[Path]:
331
+ from ct.agent.trace import TraceLogger
332
+
333
+ if path is not None and session_id is not None:
334
+ console.print("[red]Use either --path or --session-id, not both.[/red]")
335
+ raise typer.Exit(code=2)
336
+
337
+ if path is not None:
338
+ return path
339
+ if session_id:
340
+ return TraceLogger.traces_dir() / f"{session_id}.trace.jsonl"
341
+ return _latest_trace_path()
342
+
343
+
344
+ def _latest_report_path(output_base: Optional[str] = None) -> Optional[Path]:
345
+ reports_dir = (
346
+ Path(output_base) / "reports"
347
+ if output_base
348
+ else Path.cwd() / "outputs" / "reports"
349
+ )
350
+ if not reports_dir.exists():
351
+ return None
352
+ reports = list(reports_dir.glob("*.md"))
353
+ if not reports:
354
+ return None
355
+ return max(reports, key=lambda p: p.stat().st_mtime)
356
+
357
+
358
+ def _trace_has_issues(diag: dict) -> bool:
359
+ return any(
360
+ (
361
+ diag.get("unclosed_queries"),
362
+ diag.get("queries_with_no_plan"),
363
+ diag.get("queries_with_no_completion"),
364
+ diag.get("queries_with_synthesis_mismatch"),
365
+ )
366
+ )
367
+
368
+
369
+ def _print_trace_diagnostics_table(diag: dict, title: str):
370
+ table = Table(title=title)
371
+ table.add_column("Metric", style="cyan")
372
+ table.add_column("Value")
373
+ table.add_row("Session", diag.get("session_id", "(unknown)") or "(unknown)")
374
+ table.add_row("Events", str(diag.get("event_count", 0)))
375
+ table.add_row("Queries", str(diag.get("query_count", 0)))
376
+ table.add_row(
377
+ "Query starts / ends",
378
+ f"{diag.get('query_start_count', 0)} / {diag.get('query_end_count', 0)}",
379
+ )
380
+ table.add_row("Step starts", str(diag.get("total_step_start_count", 0)))
381
+ table.add_row("Step completes", str(diag.get("total_step_complete_count", 0)))
382
+ table.add_row("Step fails", str(diag.get("total_step_fail_count", 0)))
383
+ table.add_row("Step retries", str(diag.get("total_step_retry_count", 0)))
384
+ table.add_row("Unclosed queries", str(diag.get("unclosed_queries", [])))
385
+ table.add_row("Queries with failures", str(diag.get("queries_with_failures", [])))
386
+ table.add_row("Queries with no plan", str(diag.get("queries_with_no_plan", [])))
387
+ table.add_row(
388
+ "Queries with no completion",
389
+ str(diag.get("queries_with_no_completion", [])),
390
+ )
391
+ table.add_row(
392
+ "Synthesis mismatches",
393
+ str(diag.get("queries_with_synthesis_mismatch", [])),
394
+ )
395
+ console.print(table)
396
+
397
+
398
+ def _run_step_command(label: str, cmd: list[str], env: Optional[dict] = None) -> bool:
399
+ console.print(f"\n[bold cyan]{label}[/bold cyan]")
400
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
401
+ proc = subprocess.run(cmd, capture_output=True, text=True, env=env)
402
+ stdout = (proc.stdout or "").strip()
403
+ stderr = (proc.stderr or "").strip()
404
+ if stdout:
405
+ console.print(stdout)
406
+ if stderr:
407
+ style = "yellow" if proc.returncode == 0 else "red"
408
+ console.print(stderr, style=style)
409
+ if proc.returncode == 0:
410
+ console.print(f"[green]PASS[/green] {label}")
411
+ return True
412
+ console.print(f"[red]FAIL[/red] {label} (exit={proc.returncode})")
413
+ return False
414
+
415
+
416
+ @trace_app.command("diagnose")
417
+ def trace_diagnose(
418
+ path: Optional[Path] = typer.Option(None, "--path", "-p", help="Path to a trace JSONL file"),
419
+ session_id: Optional[str] = typer.Option(None, "--session-id", "-s", help="Session ID (looks up ~/.ct/traces/<id>.trace.jsonl)"),
420
+ as_json: bool = typer.Option(False, "--json", help="Print diagnostics as JSON"),
421
+ show_queries: bool = typer.Option(False, "--show-queries", help="Show per-query diagnostics table"),
422
+ strict: bool = typer.Option(False, "--strict", help="Exit non-zero if health issues are detected"),
423
+ ):
424
+ """Diagnose trace health (query integrity, failures, synthesis lifecycle)."""
425
+ from ct.agent.trace import TraceLogger
426
+
427
+ trace_path = _resolve_trace_path(path, session_id)
428
+ if trace_path is None:
429
+ console.print("[yellow]No trace files found in ~/.ct/traces[/yellow]")
430
+ raise typer.Exit(code=2)
431
+ if not trace_path.exists():
432
+ console.print(f"[red]Trace file not found:[/red] {trace_path}")
433
+ raise typer.Exit(code=2)
434
+
435
+ trace = TraceLogger.load(trace_path)
436
+ diag = trace.diagnostics()
437
+
438
+ if as_json:
439
+ console.print_json(data=diag)
440
+ else:
441
+ _print_trace_diagnostics_table(diag, title=f"Trace Diagnostics: {trace_path.name}")
442
+
443
+ if show_queries:
444
+ q_table = Table(title="Per-Query Diagnostics")
445
+ q_table.add_column("#", style="cyan")
446
+ q_table.add_column("Closed")
447
+ q_table.add_column("Plans")
448
+ q_table.add_column("Step OK")
449
+ q_table.add_column("Step Fail")
450
+ q_table.add_column("Retries")
451
+ q_table.add_column("Synth start/end")
452
+ q_table.add_column("Query")
453
+ for q in diag["queries"]:
454
+ q_table.add_row(
455
+ str(q["query_number"]),
456
+ "yes" if q["closed"] else "no",
457
+ str(q["plan_count"]),
458
+ str(q["step_complete_count"]),
459
+ str(q["step_fail_count"]),
460
+ str(q["step_retry_count"]),
461
+ f"{q['synthesize_start_count']}/{q['synthesize_end_count']}",
462
+ (q["query"] or "")[:80],
463
+ )
464
+ console.print(q_table)
465
+
466
+ has_issues = _trace_has_issues(diag)
467
+ if strict and has_issues:
468
+ raise typer.Exit(code=2)
469
+
470
+
471
+ @trace_app.command("export")
472
+ def trace_export(
473
+ path: Optional[Path] = typer.Option(None, "--path", "-p", help="Path to a trace JSONL file"),
474
+ session_id: Optional[str] = typer.Option(None, "--session-id", "-s", help="Session ID (looks up ~/.ct/traces/<id>.trace.jsonl)"),
475
+ report: Optional[Path] = typer.Option(None, "--report", "-r", help="Optional markdown report to include"),
476
+ out_dir: Optional[Path] = typer.Option(None, "--out-dir", help="Bundle output directory (default: ~/.ct/exports)"),
477
+ zip_bundle: bool = typer.Option(True, "--zip/--no-zip", help="Also produce a zip archive"),
478
+ ):
479
+ """Export a reproducible run bundle (trace, diagnostics, report, metadata)."""
480
+ from ct.agent.config import Config
481
+ from ct.agent.trace import TraceLogger
482
+ from ct.agent.trajectory import Trajectory
483
+
484
+ trace_path = _resolve_trace_path(path, session_id)
485
+ if trace_path is None:
486
+ console.print("[yellow]No trace files found in ~/.ct/traces[/yellow]")
487
+ raise typer.Exit(code=2)
488
+ if not trace_path.exists():
489
+ console.print(f"[red]Trace file not found:[/red] {trace_path}")
490
+ raise typer.Exit(code=2)
491
+
492
+ trace = TraceLogger.load(trace_path)
493
+ diag = trace.diagnostics()
494
+
495
+ cfg = Config.load()
496
+ resolved_report = report
497
+ if resolved_report is None:
498
+ resolved_report = _latest_report_path(cfg.get("sandbox.output_dir"))
499
+ if resolved_report is not None and not resolved_report.exists():
500
+ console.print(f"[red]Report file not found:[/red] {resolved_report}")
501
+ raise typer.Exit(code=2)
502
+
503
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
504
+ base = out_dir or (Path.home() / ".ct" / "exports")
505
+ bundle_dir = base / f"ct_run_bundle_{trace.session_id or 'session'}_{ts}"
506
+ bundle_dir.mkdir(parents=True, exist_ok=True)
507
+
508
+ trace_copy = bundle_dir / "trace.jsonl"
509
+ shutil.copy2(trace_path, trace_copy)
510
+ (bundle_dir / "trace.txt").write_text(trace.to_text(), encoding="utf-8")
511
+ (bundle_dir / "trace_diagnostics.json").write_text(
512
+ json.dumps(diag, indent=2, sort_keys=True),
513
+ encoding="utf-8",
514
+ )
515
+ (bundle_dir / "query_summaries.json").write_text(
516
+ json.dumps(trace.query_summaries(), indent=2),
517
+ encoding="utf-8",
518
+ )
519
+
520
+ copied_report = None
521
+ if resolved_report is not None:
522
+ copied_report = bundle_dir / "report.md"
523
+ shutil.copy2(resolved_report, copied_report)
524
+
525
+ copied_session = None
526
+ session_file = None
527
+ if trace.session_id:
528
+ session_file = Trajectory.sessions_dir() / f"{trace.session_id}.jsonl"
529
+ if session_file is not None and session_file.exists():
530
+ copied_session = bundle_dir / "session.jsonl"
531
+ shutil.copy2(session_file, copied_session)
532
+
533
+ manifest = {
534
+ "generated_at_utc": ts,
535
+ "session_id": trace.session_id,
536
+ "source_trace": str(trace_path),
537
+ "included_files": {
538
+ "trace_jsonl": str(trace_copy),
539
+ "trace_txt": str(bundle_dir / "trace.txt"),
540
+ "trace_diagnostics_json": str(bundle_dir / "trace_diagnostics.json"),
541
+ "query_summaries_json": str(bundle_dir / "query_summaries.json"),
542
+ "report_md": str(copied_report) if copied_report else None,
543
+ "session_jsonl": str(copied_session) if copied_session else None,
544
+ },
545
+ "note": (
546
+ "If report was auto-selected, it is the latest markdown report by mtime "
547
+ "from sandbox.output_dir/reports."
548
+ if report is None
549
+ else "Report path explicitly provided."
550
+ ),
551
+ }
552
+ (bundle_dir / "manifest.json").write_text(
553
+ json.dumps(manifest, indent=2, sort_keys=True),
554
+ encoding="utf-8",
555
+ )
556
+
557
+ console.print(f"[green]Bundle exported:[/green] {bundle_dir}")
558
+ if copied_report:
559
+ console.print(f"[dim]Included report:[/dim] {resolved_report}")
560
+ else:
561
+ console.print("[yellow]No report included (none found/provided).[/yellow]")
562
+
563
+ if zip_bundle:
564
+ archive = shutil.make_archive(str(bundle_dir), "zip", root_dir=bundle_dir)
565
+ console.print(f"[green]Zip archive:[/green] {archive}")
566
+
567
+
568
+ @app.command("release-check")
569
+ def release_check_cmd(
570
+ run_tests: bool = typer.Option(True, "--tests/--no-tests", help="Run local pytest regression suite"),
571
+ run_benchmark: bool = typer.Option(True, "--benchmark/--no-benchmark", help="Run strict knowledge benchmark gate"),
572
+ run_trace: bool = typer.Option(True, "--trace/--no-trace", help="Run strict diagnostics on latest trace"),
573
+ trace_path: Optional[Path] = typer.Option(None, "--trace-path", help="Trace path for diagnostics"),
574
+ trace_required: bool = typer.Option(False, "--trace-required", help="Fail if no trace file is found"),
575
+ include_live: bool = typer.Option(False, "--live", help="Also run live API smoke + live E2E prompt matrix"),
576
+ matrix_limit: int = typer.Option(10, "--matrix-limit", help="Prompt limit for live E2E matrix"),
577
+ matrix_strict: bool = typer.Option(True, "--matrix-strict/--no-matrix-strict", help="Enable strict assertions in live E2E matrix"),
578
+ matrix_max_failed: int = typer.Option(1, "--matrix-max-failed", help="Max failed prompts allowed in strict matrix mode"),
579
+ require_profile: Optional[str] = typer.Option(None, "--require-profile", help="Require agent.profile to match (e.g. pharma)"),
580
+ pharma: bool = typer.Option(False, "--pharma", help="Enforce pharma deployment policy checks"),
581
+ ):
582
+ """Run a production release gate: doctor + tests + benchmark + trace diagnostics."""
583
+ from ct.agent.config import Config
584
+ from ct.agent.doctor import has_errors, run_checks, to_table
585
+ from ct.agent.trace import TraceLogger
586
+ from ct.kb.benchmarks import BenchmarkSuite
587
+
588
+ failed = False
589
+
590
+ console.print("\n[bold]Release Check[/bold]")
591
+
592
+ cfg = Config.load()
593
+ if pharma and not require_profile:
594
+ require_profile = "pharma"
595
+
596
+ if require_profile:
597
+ expected = require_profile.strip().lower()
598
+ actual = str(cfg.get("agent.profile", "research")).strip().lower()
599
+ if actual != expected:
600
+ console.print(
601
+ f"[red]Profile mismatch:[/red] expected '{expected}', got '{actual}'."
602
+ )
603
+ failed = True
604
+
605
+ if pharma:
606
+ policy_issues = []
607
+ if str(cfg.get("agent.synthesis_style", "standard")).strip().lower() != "pharma":
608
+ policy_issues.append("agent.synthesis_style must be 'pharma'")
609
+ if not bool(cfg.get("agent.quality_gate_strict", False)):
610
+ policy_issues.append("agent.quality_gate_strict must be true")
611
+ if bool(cfg.get("agent.enable_experimental_tools", False)):
612
+ policy_issues.append("agent.enable_experimental_tools must be false")
613
+ if bool(cfg.get("agent.enable_claude_code_tool", False)):
614
+ policy_issues.append("agent.enable_claude_code_tool must be false")
615
+ if policy_issues:
616
+ console.print("[red]Pharma policy checks failed:[/red]")
617
+ for issue in policy_issues:
618
+ console.print(f"- {issue}")
619
+ failed = True
620
+
621
+ checks = run_checks(cfg)
622
+ console.print(to_table(checks))
623
+ if has_errors(checks):
624
+ console.print("[red]Doctor checks have blocking errors.[/red]")
625
+ failed = True
626
+
627
+ if run_tests:
628
+ ok = _run_step_command(
629
+ "Local test suite",
630
+ ["pytest", "-q", "tests", "-m", "not api_smoke and not e2e and not e2e_matrix"],
631
+ )
632
+ failed = failed or (not ok)
633
+
634
+ if run_benchmark:
635
+ suite = BenchmarkSuite.load()
636
+ summary = suite.run()
637
+ gate = suite.gate(summary, min_pass_rate=0.9)
638
+
639
+ table = Table(title="Release Benchmark Gate")
640
+ table.add_column("Metric", style="cyan")
641
+ table.add_column("Value")
642
+ table.add_row("Total cases", str(summary["total_cases"]))
643
+ table.add_row("Expected behavior matches", str(summary["expected_behavior_matches"]))
644
+ table.add_row("Pass rate", str(summary["pass_rate"]))
645
+ table.add_row("Gate", gate["message"])
646
+ console.print(table)
647
+
648
+ if not gate["ok"]:
649
+ console.print("[red]Benchmark release gate failed.[/red]")
650
+ failed = True
651
+
652
+ if run_trace:
653
+ resolved_trace = trace_path or _latest_trace_path()
654
+ if resolved_trace is None or not resolved_trace.exists():
655
+ msg = "No trace file found for diagnostics."
656
+ if trace_required:
657
+ console.print(f"[red]{msg}[/red]")
658
+ failed = True
659
+ else:
660
+ console.print(f"[yellow]{msg}[/yellow]")
661
+ else:
662
+ trace = TraceLogger.load(resolved_trace)
663
+ diag = trace.diagnostics()
664
+ _print_trace_diagnostics_table(diag, title=f"Trace Diagnostics: {resolved_trace.name}")
665
+ if _trace_has_issues(diag):
666
+ console.print("[red]Trace diagnostics detected integrity issues.[/red]")
667
+ failed = True
668
+
669
+ if include_live:
670
+ smoke_env = dict(os.environ)
671
+ smoke_env["CT_RUN_API_SMOKE"] = "1"
672
+ smoke_env.setdefault("CT_API_SMOKE_STRICT", "1")
673
+ smoke_ok = _run_step_command(
674
+ "Live API smoke checks",
675
+ ["pytest", "-q", "tests/test_api_smoke.py"],
676
+ env=smoke_env,
677
+ )
678
+ failed = failed or (not smoke_ok)
679
+
680
+ matrix_env = dict(os.environ)
681
+ matrix_env["CT_RUN_E2E_MATRIX"] = "1"
682
+ matrix_env["CT_E2E_MATRIX_LIMIT"] = str(max(1, matrix_limit))
683
+ matrix_env["CT_E2E_MATRIX_STRICT"] = "1" if matrix_strict else "0"
684
+ matrix_env["CT_E2E_MATRIX_MAX_FAILED_QUERIES"] = str(max(0, matrix_max_failed))
685
+ matrix_ok = _run_step_command(
686
+ "Live E2E prompt matrix",
687
+ ["pytest", "-q", "tests/test_e2e_matrix.py", "--run-e2e"],
688
+ env=matrix_env,
689
+ )
690
+ failed = failed or (not matrix_ok)
691
+
692
+ if failed:
693
+ console.print("\n[red]Release check failed.[/red]")
694
+ raise typer.Exit(code=2)
695
+
696
+ console.print("\n[green]Release check passed.[/green]")
697
+
698
+
699
+ @knowledge_app.command("status")
700
+ def knowledge_status():
701
+ """Show knowledge substrate status."""
702
+ from ct.kb.substrate import KnowledgeSubstrate
703
+
704
+ substrate = KnowledgeSubstrate()
705
+ summary = substrate.summary()
706
+ table = Table(title="Knowledge Substrate")
707
+ table.add_column("Metric", style="cyan")
708
+ table.add_column("Value")
709
+ table.add_row("Path", summary["path"])
710
+ table.add_row("Schema Version", str(summary["schema_version"]))
711
+ table.add_row("Entities", str(summary["n_entities"]))
712
+ table.add_row("Relations", str(summary["n_relations"]))
713
+ table.add_row("Evidence", str(summary["n_evidence"]))
714
+ for et, count in sorted(summary.get("entity_types", {}).items()):
715
+ table.add_row(f"entity_type:{et}", str(count))
716
+ console.print(table)
717
+
718
+
719
+ @knowledge_app.command("ingest")
720
+ def knowledge_ingest(
721
+ source: str = typer.Argument(..., help="Source: evidence_store | pubmed | openalex | opentargets"),
722
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="Query for API sources"),
723
+ max_results: int = typer.Option(10, "--max-results", help="Max records for API sources"),
724
+ scan_limit: int = typer.Option(1000, "--scan-limit", help="Max local evidence rows to scan"),
725
+ ):
726
+ """Ingest knowledge into canonical substrate."""
727
+ from ct.kb.ingest import KnowledgeIngestionPipeline
728
+
729
+ pipeline = KnowledgeIngestionPipeline()
730
+ result = pipeline.ingest(
731
+ source=source,
732
+ query=query,
733
+ max_results=max_results,
734
+ scan_limit=scan_limit,
735
+ )
736
+ if result.get("error"):
737
+ console.print(f"[red]{result['error']}[/red]")
738
+ raise typer.Exit(code=2)
739
+ console.print(result.get("summary", "Ingestion completed."))
740
+
741
+
742
+ @knowledge_app.command("search")
743
+ def knowledge_search(
744
+ query: str = typer.Argument(..., help="Search text"),
745
+ limit: int = typer.Option(20, "--limit", help="Maximum entities to return"),
746
+ ):
747
+ """Search canonical entities."""
748
+ from ct.kb.substrate import KnowledgeSubstrate
749
+
750
+ substrate = KnowledgeSubstrate()
751
+ entities = substrate.search_entities(query, limit=limit)
752
+ table = Table(title=f"Knowledge Search: {query}")
753
+ table.add_column("Entity ID", style="cyan")
754
+ table.add_column("Type")
755
+ table.add_column("Name")
756
+ table.add_column("Synonyms", style="dim")
757
+ for entity in entities:
758
+ table.add_row(entity.id, entity.entity_type, entity.name, ", ".join(entity.synonyms[:4]))
759
+ console.print(table)
760
+
761
+
762
+ @knowledge_app.command("related")
763
+ def knowledge_related(
764
+ entity_id: str = typer.Argument(..., help="Canonical entity id (e.g., gene:TP53)"),
765
+ predicate: Optional[str] = typer.Option(None, "--predicate", help="Filter predicate"),
766
+ limit: int = typer.Option(20, "--limit", help="Maximum relations"),
767
+ ):
768
+ """Show related entities for an entity."""
769
+ from ct.kb.substrate import KnowledgeSubstrate
770
+
771
+ substrate = KnowledgeSubstrate()
772
+ rows = substrate.related_entities(entity_id, predicate=predicate, limit=limit)
773
+ table = Table(title=f"Related Entities: {entity_id}")
774
+ table.add_column("Predicate", style="cyan")
775
+ table.add_column("Other Entity")
776
+ table.add_column("Support")
777
+ table.add_column("Contradict")
778
+ table.add_column("Avg Score")
779
+ for row in rows:
780
+ table.add_row(
781
+ row["predicate"],
782
+ row["other_entity_id"],
783
+ str(row["support_claims"]),
784
+ str(row["contradict_claims"]),
785
+ str(row["average_claim_score"]),
786
+ )
787
+ console.print(table)
788
+
789
+
790
+ @knowledge_app.command("rank")
791
+ def knowledge_rank(
792
+ entity_id: Optional[str] = typer.Option(None, "--entity-id", help="Entity id filter"),
793
+ predicate: Optional[str] = typer.Option(None, "--predicate", help="Predicate filter"),
794
+ limit: int = typer.Option(20, "--limit", help="Maximum relations"),
795
+ ):
796
+ """Rank relations by evidence strength."""
797
+ from ct.kb.reasoning import EvidenceReasoner
798
+ from ct.kb.substrate import KnowledgeSubstrate
799
+
800
+ reasoner = EvidenceReasoner(KnowledgeSubstrate())
801
+ rows = reasoner.rank_relations(entity_id=entity_id, predicate=predicate, limit=limit)
802
+ table = Table(title="Ranked Relations")
803
+ table.add_column("Relation", style="cyan")
804
+ table.add_column("Score")
805
+ table.add_column("Claims")
806
+ for row in rows:
807
+ relation = f"{row['subject_id']} --{row['predicate']}--> {row['object_id']}"
808
+ table.add_row(relation, str(row["score"]), str(row["n_claims"]))
809
+ console.print(table)
810
+
811
+
812
+ @knowledge_app.command("contradictions")
813
+ def knowledge_contradictions(
814
+ entity_id: Optional[str] = typer.Option(None, "--entity-id", help="Entity id filter"),
815
+ predicate: Optional[str] = typer.Option(None, "--predicate", help="Predicate filter"),
816
+ ):
817
+ """Detect contradictory evidence clusters."""
818
+ from ct.kb.reasoning import EvidenceReasoner
819
+ from ct.kb.substrate import KnowledgeSubstrate
820
+
821
+ reasoner = EvidenceReasoner(KnowledgeSubstrate())
822
+ rows = reasoner.detect_contradictions(entity_id=entity_id, predicate=predicate)
823
+ table = Table(title="Contradictions")
824
+ table.add_column("Relation", style="cyan")
825
+ table.add_column("Support")
826
+ table.add_column("Contradict")
827
+ table.add_column("Support Score")
828
+ table.add_column("Contradict Score")
829
+ for row in rows:
830
+ relation = f"{row['subject_id']} --{row['predicate']}--> {row['object_id']}"
831
+ table.add_row(
832
+ relation,
833
+ str(row["support_claims"]),
834
+ str(row["contradict_claims"]),
835
+ str(row["support_score"]),
836
+ str(row["contradict_score"]),
837
+ )
838
+ console.print(table)
839
+
840
+
841
+ @knowledge_app.command("schema-check")
842
+ def knowledge_schema_check():
843
+ """Run schema drift checks against external integration baselines."""
844
+ from ct.kb.schema_monitor import SchemaMonitor
845
+
846
+ monitor = SchemaMonitor()
847
+ results = monitor.check()
848
+ summary = monitor.summarize(results)
849
+ table = Table(title="Schema Drift Monitor")
850
+ table.add_column("Monitor", style="cyan")
851
+ table.add_column("Status")
852
+ table.add_column("Added")
853
+ table.add_column("Removed")
854
+ table.add_column("Error")
855
+ for row in summary["results"]:
856
+ table.add_row(
857
+ row["monitor"],
858
+ row["status"],
859
+ str(len(row["added_paths"])),
860
+ str(len(row["removed_paths"])),
861
+ row.get("error", ""),
862
+ )
863
+ console.print(table)
864
+ if summary["counts"].get("drift", 0) > 0 or summary["counts"].get("error", 0) > 0:
865
+ raise typer.Exit(code=2)
866
+
867
+
868
+ @knowledge_app.command("schema-update")
869
+ def knowledge_schema_update(monitor: Optional[str] = typer.Option(None, "--monitor", help="Single monitor to update")):
870
+ """Update schema drift baselines from current responses."""
871
+ from ct.kb.schema_monitor import SchemaMonitor
872
+
873
+ mon = SchemaMonitor()
874
+ results = mon.update_baseline(monitor=monitor)
875
+ summary = mon.summarize(results)
876
+ console.print(f"Updated schema baseline for {summary['total']} monitor(s).")
877
+
878
+
879
+ @knowledge_app.command("benchmark")
880
+ def knowledge_benchmark(
881
+ min_pass_rate: float = typer.Option(0.9, "--min-pass-rate", help="Release gate threshold"),
882
+ strict: bool = typer.Option(False, "--strict", help="Exit non-zero if gate fails"),
883
+ ):
884
+ """Run benchmark suite and evaluate release gate."""
885
+ from ct.kb.benchmarks import BenchmarkSuite
886
+
887
+ suite = BenchmarkSuite.load()
888
+ summary = suite.run()
889
+ gate = suite.gate(summary, min_pass_rate=min_pass_rate)
890
+
891
+ table = Table(title="Knowledge Benchmarks")
892
+ table.add_column("Metric", style="cyan")
893
+ table.add_column("Value")
894
+ table.add_row("Total cases", str(summary["total_cases"]))
895
+ table.add_row("Expected behavior matches", str(summary["expected_behavior_matches"]))
896
+ table.add_row("Pass rate", str(summary["pass_rate"]))
897
+ table.add_row("Gate", gate["message"])
898
+ console.print(table)
899
+ if strict and not gate["ok"]:
900
+ raise typer.Exit(code=2)
901
+
902
+
903
+ # ─── Report subcommands ──────────────────────────────────────
904
+
905
+ report_app = typer.Typer(help="Generate and publish reports")
906
+ app.add_typer(report_app, name="report")
907
+
908
+
909
+ @report_app.command("list")
910
+ def report_list():
911
+ """List available markdown reports."""
912
+ from ct.agent.config import Config
913
+
914
+ cfg = Config.load()
915
+ reports_dir = (
916
+ Path(cfg.get("sandbox.output_dir")) / "reports"
917
+ if cfg.get("sandbox.output_dir")
918
+ else Path.cwd() / "outputs" / "reports"
919
+ )
920
+ if not reports_dir.exists():
921
+ console.print("[dim]No reports directory found.[/dim]")
922
+ raise typer.Exit()
923
+
924
+ reports = sorted(reports_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
925
+ if not reports:
926
+ console.print("[dim]No reports found.[/dim]")
927
+ raise typer.Exit()
928
+
929
+ table = Table(title="Reports")
930
+ table.add_column("#", style="dim")
931
+ table.add_column("File", style="cyan")
932
+ table.add_column("Size", style="dim")
933
+ table.add_column("Modified")
934
+ for i, r in enumerate(reports[:20], 1):
935
+ size = r.stat().st_size
936
+ size_str = f"{size / 1024:.1f}K" if size > 1024 else f"{size}B"
937
+ mtime = datetime.fromtimestamp(r.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
938
+ table.add_row(str(i), r.name, size_str, mtime)
939
+ console.print(table)
940
+
941
+
942
+ @report_app.command("publish")
943
+ def report_publish(
944
+ path: Optional[Path] = typer.Option(None, "--path", "-p", help="Markdown report to convert"),
945
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output HTML path"),
946
+ ):
947
+ """Convert a markdown report to a shareable HTML page."""
948
+ from ct.agent.config import Config
949
+ from ct.reports.html import publish_report
950
+
951
+ if path is None:
952
+ cfg = Config.load()
953
+ path = _latest_report_path(cfg.get("sandbox.output_dir"))
954
+ if path is None:
955
+ console.print("[yellow]No reports found. Run a query first.[/yellow]")
956
+ raise typer.Exit(code=2)
957
+
958
+ if not path.exists():
959
+ console.print(f"[red]File not found:[/red] {path}")
960
+ raise typer.Exit(code=2)
961
+
962
+ result = publish_report(path, out_path=out)
963
+ console.print(f"[green]Published:[/green] {result}")
964
+
965
+
966
+ @report_app.command("show")
967
+ def report_show(
968
+ path: Optional[Path] = typer.Option(None, "--path", "-p", help="HTML report to open"),
969
+ ):
970
+ """Open an HTML report in the default browser."""
971
+ import webbrowser
972
+
973
+ from ct.agent.config import Config
974
+ from ct.reports.html import publish_report
975
+
976
+ if path is None:
977
+ cfg = Config.load()
978
+ md_path = _latest_report_path(cfg.get("sandbox.output_dir"))
979
+ if md_path is None:
980
+ console.print("[yellow]No reports found.[/yellow]")
981
+ raise typer.Exit(code=2)
982
+ html_path = md_path.with_suffix(".html")
983
+ if not html_path.exists():
984
+ html_path = publish_report(md_path)
985
+ console.print(f"[dim]Auto-published: {html_path}[/dim]")
986
+ path = html_path
987
+
988
+ if not path.exists():
989
+ console.print(f"[red]File not found:[/red] {path}")
990
+ raise typer.Exit(code=2)
991
+
992
+ webbrowser.open(f"file://{path.resolve()}")
993
+ console.print(f"[green]Opened in browser:[/green] {path}")
994
+
995
+
996
+ @report_app.command("notebook")
997
+ def report_notebook(
998
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (prefix or full). Default: most recent"),
999
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output notebook path"),
1000
+ html: bool = typer.Option(False, "--html", help="Also export as HTML"),
1001
+ ):
1002
+ """Export an agent trace as a Jupyter notebook (.ipynb)."""
1003
+ import re
1004
+ from ct.agent.trace_store import TraceStore
1005
+
1006
+ # Find trace file
1007
+ trace_path = TraceStore.find_trace(session)
1008
+ if trace_path is None:
1009
+ console.print("[yellow]No trace files found. Run a query first to generate a trace.[/yellow]")
1010
+ raise typer.Exit(code=2)
1011
+
1012
+ console.print(f" [dim]Trace:[/dim] {trace_path.name}")
1013
+
1014
+ # Lazy import nbformat
1015
+ try:
1016
+ from ct.reports.notebook import trace_to_notebook, save_notebook
1017
+ except ImportError:
1018
+ console.print("[red]nbformat is required. Install with:[/red] pip install nbformat")
1019
+ raise typer.Exit(code=2)
1020
+
1021
+ # Convert trace to notebook
1022
+ nb = trace_to_notebook(trace_path)
1023
+
1024
+ # Determine output path
1025
+ if out is None:
1026
+ from ct.agent.config import Config
1027
+ cfg = Config.load()
1028
+ reports_dir = (
1029
+ Path(cfg.get("sandbox.output_dir")) / "reports"
1030
+ if cfg.get("sandbox.output_dir")
1031
+ else Path.cwd() / "outputs" / "reports"
1032
+ )
1033
+ slug = re.sub(r"[^a-zA-Z0-9]+", "_", trace_path.stem.replace(".trace", "")).strip("_")
1034
+ out = reports_dir / f"{slug}.ipynb"
1035
+
1036
+ out_path = save_notebook(nb, out)
1037
+ console.print(f" [green]Notebook:[/green] {out_path}")
1038
+
1039
+ # Optional HTML export
1040
+ if html:
1041
+ try:
1042
+ import nbconvert
1043
+ from nbconvert import HTMLExporter
1044
+ exporter = HTMLExporter()
1045
+ html_body, _ = exporter.from_notebook_node(nb)
1046
+ html_path = out_path.with_suffix(".html")
1047
+ html_path.write_text(html_body, encoding="utf-8")
1048
+ console.print(f" [green]HTML:[/green] {html_path}")
1049
+ except ImportError:
1050
+ console.print(
1051
+ "[yellow]nbconvert not installed. Falling back to markdown-based HTML.[/yellow]\n"
1052
+ " [dim]Install with: pip install nbconvert[/dim]"
1053
+ )
1054
+ # Fall back to existing HTML renderer on markdown cells
1055
+ from ct.reports.html import render_html_report
1056
+ md_parts = [c.source for c in nb.cells if c.cell_type == "markdown"]
1057
+ md_text = "\n\n".join(md_parts)
1058
+ html_content = render_html_report(md_text, title="ct Notebook Export")
1059
+ html_path = out_path.with_suffix(".html")
1060
+ html_path.write_text(html_content, encoding="utf-8")
1061
+ console.print(f" [green]HTML (markdown only):[/green] {html_path}")
1062
+
1063
+
1064
+ # ─── Case study subcommands ─────────────────────────────────
1065
+
1066
+ case_study_app = typer.Typer(help="Run curated drug case studies")
1067
+ app.add_typer(case_study_app, name="case-study")
1068
+
1069
+
1070
+ @case_study_app.command("list")
1071
+ def case_study_list():
1072
+ """List available curated case studies."""
1073
+ from ct.agent.case_studies import CASE_STUDIES
1074
+
1075
+ table = Table(title="Case Studies")
1076
+ table.add_column("ID", style="cyan")
1077
+ table.add_column("Drug")
1078
+ table.add_column("Threads", style="dim")
1079
+ table.add_column("Description")
1080
+ for case_id, case in CASE_STUDIES.items():
1081
+ table.add_row(
1082
+ case_id,
1083
+ case.name,
1084
+ str(len(case.thread_goals)),
1085
+ case.description[:80] + ("..." if len(case.description) > 80 else ""),
1086
+ )
1087
+ console.print(table)
1088
+
1089
+
1090
+ @case_study_app.command("run")
1091
+ def case_study_run(
1092
+ case_id: str = typer.Argument(..., help="Case study ID (e.g., revlimid, gleevec)"),
1093
+ threads: Optional[int] = typer.Option(None, "--threads", "-t", help="Number of parallel threads"),
1094
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
1095
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
1096
+ ):
1097
+ """Run a curated drug case study with multi-agent analysis."""
1098
+ from ct.agent.case_studies import CASE_STUDIES, run_case_study
1099
+ from ct.agent.config import Config
1100
+ from ct.reports.html import publish_report
1101
+
1102
+ if case_id not in CASE_STUDIES:
1103
+ available = ", ".join(sorted(CASE_STUDIES.keys()))
1104
+ console.print(f"[red]Unknown case study '{case_id}'.[/red] Available: {available}")
1105
+ raise typer.Exit(code=2)
1106
+
1107
+ cfg = Config.load()
1108
+ if model:
1109
+ cfg.set("llm.model", model)
1110
+
1111
+ llm_issue = cfg.llm_preflight_issue()
1112
+ if llm_issue:
1113
+ console.print(
1114
+ Panel(
1115
+ f"[bold red]LLM is not configured[/bold red]\n\n{llm_issue}",
1116
+ title="[red]Configuration Error[/red]",
1117
+ border_style="red",
1118
+ )
1119
+ )
1120
+ raise typer.Exit(code=2)
1121
+
1122
+ session = Session(config=cfg, verbose=verbose)
1123
+ case = CASE_STUDIES[case_id]
1124
+
1125
+ print_banner()
1126
+ console.print(Panel(
1127
+ f"[bold]{case.name}[/bold]\n[dim]{case.description}[/dim]",
1128
+ title="[cyan]Case Study[/cyan]",
1129
+ border_style="cyan",
1130
+ ))
1131
+ console.print()
1132
+
1133
+ result = run_case_study(session, case_id, n_threads=threads)
1134
+
1135
+ # Auto-publish HTML
1136
+ md_path = _latest_report_path(cfg.get("sandbox.output_dir"))
1137
+ if md_path:
1138
+ html_path = publish_report(md_path)
1139
+ console.print(f"\n [green]HTML report:[/green] {html_path}")
1140
+
1141
+ console.print()
1142
+
1143
+
1144
+ # ─── Main entry point ─────────────────────────────────────────
1145
+
1146
+ @app.command("run", hidden=True)
1147
+ def run_cmd(
1148
+ query_parts: list[str] = typer.Argument(None, help="Research question to investigate"),
1149
+ smiles: Optional[str] = typer.Option(None, "--smiles", "-s", help="Compound SMILES string"),
1150
+ target: Optional[str] = typer.Option(None, "--target", "-t", help="Target protein (UniProt ID or gene symbol)"),
1151
+ indication: Optional[str] = typer.Option(None, "--indication", "-i", help="Cancer type / indication"),
1152
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory for reports"),
1153
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
1154
+ agents: Optional[int] = typer.Option(None, "--agents", "-a", help="Run with N parallel research agents"),
1155
+ resume: Optional[str] = typer.Option(None, "--resume", "-r", help="Resume a previous session (ID or 'last')"),
1156
+ continue_last: bool = typer.Option(False, "--continue", "-c", help="Continue the most recent session"),
1157
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
1158
+ version: bool = typer.Option(False, "--version", "-V", help="Show version"),
1159
+ ):
1160
+ """
1161
+ CellType CLI — An autonomous agent for drug discovery research.
1162
+
1163
+ Run without arguments for interactive mode.
1164
+ Pass a question for single-query mode.
1165
+ """
1166
+ if version:
1167
+ console.print(f"ct v{__version__}")
1168
+ raise typer.Exit()
1169
+
1170
+ query = " ".join(query_parts).strip() if query_parts else None
1171
+
1172
+ # Build context from flags
1173
+ context = {}
1174
+ if smiles:
1175
+ context["compound_smiles"] = smiles
1176
+ if target:
1177
+ context["target"] = target
1178
+ if indication:
1179
+ context["indication"] = indication
1180
+
1181
+ # Determine session resume
1182
+ resume_id = None
1183
+ if continue_last:
1184
+ resume_id = "last"
1185
+ elif resume:
1186
+ resume_id = resume
1187
+
1188
+ if query:
1189
+ # Single query mode
1190
+ run_query(query, context, output, model, verbose, agents=agents)
1191
+ else:
1192
+ # Interactive mode
1193
+ run_interactive(context, output, model, verbose, resume_id=resume_id)
1194
+
1195
+
1196
+ def run_query(query: str, context: dict, output: Optional[Path],
1197
+ model: Optional[str], verbose: bool, agents: Optional[int] = None):
1198
+ """Execute a single research query."""
1199
+ from ct.agent.config import Config
1200
+
1201
+ cfg = Config.load()
1202
+ if model:
1203
+ cfg.set("llm.model", model)
1204
+
1205
+ llm_issue = cfg.llm_preflight_issue()
1206
+ if llm_issue:
1207
+ console.print(
1208
+ Panel(
1209
+ (
1210
+ f"[bold red]LLM is not configured[/bold red]\n\n{llm_issue}\n\n"
1211
+ "Run `ct doctor` for a full readiness check."
1212
+ ),
1213
+ title="[red]Configuration Error[/red]",
1214
+ border_style="red",
1215
+ )
1216
+ )
1217
+ raise typer.Exit(code=2)
1218
+
1219
+ session = Session(config=cfg, verbose=verbose)
1220
+
1221
+ print_banner()
1222
+ console.print(Panel(
1223
+ f"[bold]{query}[/bold]",
1224
+ title="[cyan]ct[/cyan]",
1225
+ border_style="cyan",
1226
+ ))
1227
+ console.print()
1228
+
1229
+ # Multi-agent mode
1230
+ if agents is not None and agents > 1:
1231
+ from ct.agent.orchestrator import ResearchOrchestrator
1232
+ orchestrator = ResearchOrchestrator(session, n_threads=agents)
1233
+ result = orchestrator.run(query, context)
1234
+
1235
+ if output:
1236
+ output.mkdir(parents=True, exist_ok=True)
1237
+ report_path = output / "report.md"
1238
+ report_path.write_text(result.to_markdown())
1239
+ console.print(f"\n Report saved to {report_path}")
1240
+
1241
+ console.print()
1242
+ return
1243
+
1244
+ # Execute via Agent SDK runner (default) or legacy AgentLoop (fallback)
1245
+ use_sdk = cfg.get("agent.use_sdk", True)
1246
+
1247
+ if use_sdk:
1248
+ from ct.agent.runner import AgentRunner
1249
+ agent = AgentRunner(session)
1250
+ result = agent.run(query, context)
1251
+ else:
1252
+ from ct.agent.loop import AgentLoop, ClarificationNeeded
1253
+ agent = AgentLoop(session)
1254
+ try:
1255
+ result = agent.run(query, context)
1256
+ except ClarificationNeeded as e:
1257
+ console.print(f"\n [cyan]{e.clarification.question}[/cyan]")
1258
+ if e.clarification.suggestions:
1259
+ console.print(f" [dim]e.g. {', '.join(e.clarification.suggestions[:3])}[/dim]")
1260
+ console.print(f"\n [dim]Tip: provide context with --smiles, --target, or --indication flags.[/dim]")
1261
+ return
1262
+
1263
+ # Output
1264
+ if output:
1265
+ output.mkdir(parents=True, exist_ok=True)
1266
+ report_path = output / "report.md"
1267
+ report_path.write_text(result.to_markdown())
1268
+ console.print(f"\n Report saved to {report_path}")
1269
+
1270
+ # Summary already streamed to stdout during synthesis
1271
+ console.print()
1272
+
1273
+
1274
+ @app.command("bench")
1275
+ def bench(
1276
+ question: Optional[str] = typer.Option(None, "--question", "-q", help="Run a single question by ID"),
1277
+ parallel: int = typer.Option(10, "--parallel", "-p", help="Number of parallel workers"),
1278
+ timeout: int = typer.Option(300, "--timeout", help="Timeout per question in seconds"),
1279
+ max_turns: int = typer.Option(15, "--max-turns", help="Max agentic loop turns"),
1280
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model override"),
1281
+ eval_model: str = typer.Option("claude-sonnet-4-5-20250929", "--eval-model", help="Model for LLM-as-judge evaluation"),
1282
+ manifest: str = typer.Option("/mnt/bixbench/manifest.json", "--manifest", help="Path to manifest JSON"),
1283
+ output: str = typer.Option("/mnt/bixbench/outputs", "--output", "-o", help="Output directory"),
1284
+ only_failed: bool = typer.Option(False, "--only-failed", help="Re-run only failed questions"),
1285
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview questions without executing"),
1286
+ no_eval: bool = typer.Option(False, "--no-eval", help="Skip inline LLM evaluation"),
1287
+ force: bool = typer.Option(False, "--force", "-f", help="Clear previous results and re-run everything"),
1288
+ max_questions: Optional[int] = typer.Option(None, "--max-questions", "-n", help="Limit to first N questions"),
1289
+ ):
1290
+ """Run the BixBench-50 benchmark suite."""
1291
+ import shutil as _shutil
1292
+ from ct.bench.runner import BenchRunner
1293
+
1294
+ if force:
1295
+ out = Path(output)
1296
+ for sub in ("results", "evals", ".preview_cache"):
1297
+ d = out / sub
1298
+ if d.exists():
1299
+ _shutil.rmtree(d)
1300
+ for f in ("all_results.json", "llm_eval.json"):
1301
+ p = out / f
1302
+ if p.exists():
1303
+ p.unlink()
1304
+ console.print(f" [dim]Cleared {out}[/dim]")
1305
+
1306
+ if dry_run:
1307
+ import json as _json
1308
+ with open(manifest) as f:
1309
+ questions = _json.load(f)
1310
+ if question:
1311
+ questions = [q for q in questions if q["question_id"] == question]
1312
+ if max_questions:
1313
+ questions = questions[:max_questions]
1314
+
1315
+ table = Table(title=f"BixBench Dry Run — {len(questions)} questions")
1316
+ table.add_column("#", width=4)
1317
+ table.add_column("Question ID", style="cyan", width=14)
1318
+ table.add_column("Data", width=5)
1319
+ table.add_column("Question", max_width=60)
1320
+ table.add_column("Ideal", max_width=30)
1321
+
1322
+ for i, q in enumerate(questions, 1):
1323
+ has_data = "Y" if q.get("data_dir") and Path(q["data_dir"]).exists() else "N"
1324
+ table.add_row(
1325
+ str(i), q["question_id"], has_data,
1326
+ q["question"][:60], q["ideal"][:30],
1327
+ )
1328
+ console.print(table)
1329
+ return
1330
+
1331
+ runner = BenchRunner(
1332
+ manifest_path=manifest,
1333
+ output_dir=output,
1334
+ parallel=parallel,
1335
+ timeout=timeout,
1336
+ max_turns=max_turns,
1337
+ model=model,
1338
+ eval_model=eval_model,
1339
+ no_eval=no_eval,
1340
+ only_failed=only_failed,
1341
+ question_id=question,
1342
+ max_questions=max_questions,
1343
+ )
1344
+
1345
+ summary = runner.run()
1346
+ if summary.get("total"):
1347
+ console.print(
1348
+ f"\n[bold]Score: {summary['passed']}/{summary['total']} "
1349
+ f"({summary['accuracy']:.1%})[/bold]"
1350
+ )
1351
+
1352
+
1353
+ def print_banner():
1354
+ """Print the startup banner with molecule illustration."""
1355
+ from ct.tools import registry, ensure_loaded
1356
+ from rich.panel import Panel
1357
+ from rich.text import Text
1358
+ ensure_loaded()
1359
+ n_tools = len(registry.list_tools())
1360
+
1361
+ # Print the ASCII logo (just the CELLTYPE art)
1362
+ console.print(BANNER)
1363
+
1364
+ # Create a nice enclosed dashboard panel for the metadata
1365
+ meta_text = Text.from_markup(
1366
+ f"[bold white]Autonomous Drug Discovery Agent[/]\n"
1367
+ f"[dim]v{__version__} · {n_tools} tools loaded · backed by[/dim] [bold white on #f26522] Y [/][bold #f26522] Combinator[/]",
1368
+ justify="center"
1369
+ )
1370
+
1371
+ console.print(Panel(
1372
+ meta_text,
1373
+ title="[bold cyan]CellType CLI[/]",
1374
+ border_style="dim",
1375
+ width=65
1376
+ ))
1377
+
1378
+
1379
+ def run_interactive(context: dict, output: Optional[Path],
1380
+ model: Optional[str], verbose: bool, resume_id: str = None):
1381
+ """Run interactive session."""
1382
+ from ct.agent.config import Config
1383
+
1384
+ cfg = Config.load()
1385
+ if model:
1386
+ cfg.set("llm.model", model)
1387
+
1388
+ llm_issue = cfg.llm_preflight_issue()
1389
+ if llm_issue:
1390
+ console.print(
1391
+ Panel(
1392
+ (
1393
+ f"[bold red]LLM is not configured[/bold red]\n\n{llm_issue}\n\n"
1394
+ "Set your key/provider via `ct config set ...`, then run `ct` again.\n"
1395
+ "Tip: run `ct doctor` for a full readiness check."
1396
+ ),
1397
+ title="[red]Configuration Error[/red]",
1398
+ border_style="red",
1399
+ )
1400
+ )
1401
+ return
1402
+
1403
+ print_banner()
1404
+
1405
+ # Show model info like Claude Code does
1406
+ console.print(" [dim]Type a research question, or /help for commands.[/dim]")
1407
+ console.print()
1408
+
1409
+ terminal = InteractiveTerminal(config=cfg, verbose=verbose)
1410
+ terminal.run(initial_context=context, resume_id=resume_id)
1411
+
1412
+
1413
+ def entry():
1414
+ """Package entry point."""
1415
+ argv = list(sys.argv[1:])
1416
+ passthrough = {
1417
+ "config",
1418
+ "data",
1419
+ "tool",
1420
+ "trace",
1421
+ "knowledge",
1422
+ "keys",
1423
+ "doctor",
1424
+ "setup",
1425
+ "release-check",
1426
+ "report",
1427
+ "case-study",
1428
+ "bench",
1429
+ "run",
1430
+ "--help",
1431
+ "-h",
1432
+ "--install-completion",
1433
+ "--show-completion",
1434
+ }
1435
+
1436
+ # Route plain invocations to hidden `run` command so:
1437
+ # ct -> interactive mode
1438
+ # ct "question" -> single-query mode
1439
+ # ct --smiles ... "q" -> single-query with context
1440
+ # while preserving explicit subcommands like `ct config ...`.
1441
+ if not argv or argv[0] not in passthrough:
1442
+ argv = ["run", *argv]
1443
+
1444
+ app(args=argv, prog_name="ct")
1445
+
1446
+
1447
+ if __name__ == "__main__":
1448
+ entry()