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/agent/config.py ADDED
@@ -0,0 +1,523 @@
1
+ """
2
+ Configuration management for ct.
3
+
4
+ Config is stored at ~/.ct/config.json and manages:
5
+ - LLM provider settings (Anthropic, OpenAI, local models)
6
+ - Data directory paths (DepMap, PRISM, etc.)
7
+ - Output preferences
8
+ - Tool-specific settings
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from dotenv import load_dotenv
18
+
19
+ # Load .env from current dir and project root
20
+ load_dotenv()
21
+ load_dotenv(Path(__file__).resolve().parents[3] / ".env") # repo root
22
+
23
+ from rich.table import Table
24
+
25
+ CONFIG_DIR = Path.home() / ".ct"
26
+ CONFIG_FILE = CONFIG_DIR / "config.json"
27
+ VALID_LLM_PROVIDERS = frozenset({"anthropic", "openai", "local", "gluelm"})
28
+ logger = logging.getLogger("ct.config")
29
+
30
+ DEFAULTS = {
31
+ "llm.provider": "anthropic",
32
+ "llm.model": "claude-sonnet-4-5-20250929",
33
+ "llm.api_key": None,
34
+ "llm.openai_api_key": None,
35
+ "llm.temperature": 0.1,
36
+
37
+ "data.base": str(CONFIG_DIR / "data"),
38
+ "data.depmap": None,
39
+ "data.prism": None,
40
+ "data.l1000": None,
41
+ "data.msigdb": None,
42
+ "data.alphafold": None,
43
+ "data.string": None,
44
+ "data.proteomics": None,
45
+
46
+ "api.data_endpoint": None,
47
+ "api.clue_key": None,
48
+
49
+ "output.format": "markdown",
50
+ "output.verbose": False,
51
+ "output.auto_publish_html_interactive": True,
52
+ "output.auto_publish_html_batch": False,
53
+
54
+ "ui.spinner": "benzene_breathing",
55
+
56
+ "models.gluelm": None,
57
+ "models.deepternary": None,
58
+ "models.boltz2": None,
59
+
60
+ "api.ibm_rxn_key": None,
61
+ "api.lens_key": None,
62
+
63
+ "notification.sendgrid_api_key": None,
64
+ "notification.from_email": None,
65
+ "notification.auto_send": False,
66
+
67
+ "compute.lambda_api_key": None,
68
+ "compute.runpod_api_key": None,
69
+ "compute.default_provider": "lambda",
70
+
71
+ "sandbox.timeout": 30,
72
+ "sandbox.output_dir": str(Path.cwd() / "outputs"),
73
+ "sandbox.max_retries": 2,
74
+
75
+ "agent.max_iterations": 3,
76
+ "agent.enable_experimental_tools": False,
77
+ "agent.observer_model": None,
78
+ "agent.executor_max_retries": 2,
79
+ "agent.executor_loop_limit": 50,
80
+ "agent.observer_confidence_threshold": 0.8,
81
+ "agent.synthesis_max_tokens": 8192,
82
+ "agent.enforce_grounded_synthesis": True,
83
+ "agent.enforce_claim_content_validation": True,
84
+ "agent.confidence_scoring_enabled": True,
85
+ "agent.min_step_success_rate": 0.5,
86
+ "agent.require_key_evidence_section": True,
87
+ "agent.allow_creative_hypotheses": True,
88
+ "agent.max_hypotheses": 3,
89
+ "agent.grounding_repair_retries": 1,
90
+ "agent.log_evidence_store": True,
91
+ "agent.memory_retrieval_enabled": True,
92
+ "agent.memory_retrieval_limit": 3,
93
+ "agent.verifier_model": None,
94
+ "agent.verifier_provider": None,
95
+ "agent.verifier_repair_retries": 1,
96
+ "agent.quality_gate_enabled": True,
97
+ "agent.quality_gate_strict": False,
98
+ "agent.quality_gate_repair_retries": 1,
99
+ "agent.quality_gate_repair_non_strict": False,
100
+ "agent.quality_gate_min_next_steps": 2,
101
+ "agent.quality_gate_max_next_steps": 3,
102
+ "agent.synthesis_style": "standard",
103
+ "agent.profile": "research",
104
+ "agent.enable_claude_code_tool": False,
105
+ "agent.parallel_default_count": 3,
106
+ "agent.parallel_auto_suggest": True,
107
+ "agent.parallel_max_threads": 5,
108
+ "agent.planner_max_tools": 90,
109
+ "agent.planner_compact_tool_descriptions": True,
110
+ "agent.tool_health_enabled": True,
111
+ "agent.tool_health_fail_threshold": 2,
112
+ "agent.tool_health_failure_window_s": 1800,
113
+ "agent.tool_health_suppress_seconds": 900,
114
+ "agent.preflight_validation_enabled": True,
115
+
116
+ "enterprise.enforce_policy": False,
117
+ "enterprise.audit_enabled": True,
118
+ "enterprise.audit_dir": str(Path.home() / ".ct" / "audit"),
119
+ "enterprise.blocked_tools": "",
120
+ "enterprise.blocked_categories": "",
121
+ "enterprise.require_tool_allowlist": False,
122
+ "enterprise.tool_allowlist": "",
123
+ "enterprise.max_cost_usd_per_query": 0.0,
124
+
125
+ "knowledge.enable_substrate": True,
126
+ "knowledge.auto_ingest_evidence": True,
127
+ "knowledge.substrate_path": str(Path.home() / ".ct" / "knowledge" / "substrate.json"),
128
+ "knowledge.schema_monitor_enabled": False,
129
+
130
+ "ops.base_dir": str(Path.home() / ".ct" / "ops"),
131
+ }
132
+
133
+ AGENT_PROFILE_PRESETS = {
134
+ "research": {
135
+ "agent.enforce_grounded_synthesis": True,
136
+ "agent.enforce_claim_content_validation": True,
137
+ "agent.require_key_evidence_section": True,
138
+ "agent.allow_creative_hypotheses": True,
139
+ "agent.quality_gate_enabled": True,
140
+ "agent.quality_gate_strict": False,
141
+ "agent.quality_gate_repair_retries": 1,
142
+ "agent.quality_gate_repair_non_strict": False,
143
+ "agent.synthesis_style": "standard",
144
+ "agent.memory_retrieval_enabled": True,
145
+ "agent.enable_claude_code_tool": False,
146
+ "enterprise.enforce_policy": False,
147
+ "enterprise.blocked_tools": "",
148
+ "enterprise.blocked_categories": "",
149
+ "enterprise.require_tool_allowlist": False,
150
+ },
151
+ "enterprise": {
152
+ "agent.enforce_grounded_synthesis": True,
153
+ "agent.enforce_claim_content_validation": True,
154
+ "agent.require_key_evidence_section": True,
155
+ "agent.allow_creative_hypotheses": False,
156
+ "agent.quality_gate_enabled": True,
157
+ "agent.quality_gate_strict": True,
158
+ "agent.quality_gate_repair_retries": 2,
159
+ "agent.quality_gate_repair_non_strict": True,
160
+ "agent.synthesis_style": "standard",
161
+ "agent.memory_retrieval_enabled": True,
162
+ "agent.enable_claude_code_tool": False,
163
+ "enterprise.enforce_policy": True,
164
+ "enterprise.blocked_tools": "shell.run,files.delete_file,claude.code",
165
+ "enterprise.blocked_categories": "",
166
+ "enterprise.require_tool_allowlist": False,
167
+ },
168
+ "pharma": {
169
+ "agent.enforce_grounded_synthesis": True,
170
+ "agent.enforce_claim_content_validation": True,
171
+ "agent.require_key_evidence_section": True,
172
+ "agent.allow_creative_hypotheses": False,
173
+ "agent.quality_gate_enabled": True,
174
+ "agent.quality_gate_strict": True,
175
+ "agent.quality_gate_repair_retries": 2,
176
+ "agent.quality_gate_repair_non_strict": True,
177
+ "agent.quality_gate_min_next_steps": 3,
178
+ "agent.quality_gate_max_next_steps": 3,
179
+ "agent.synthesis_style": "pharma",
180
+ "agent.memory_retrieval_enabled": True,
181
+ "agent.enable_claude_code_tool": False,
182
+ "enterprise.enforce_policy": False,
183
+ "enterprise.blocked_tools": "",
184
+ "enterprise.blocked_categories": "",
185
+ "enterprise.require_tool_allowlist": False,
186
+ },
187
+ }
188
+
189
+
190
+ API_KEYS = {
191
+ "api.ibm_rxn_key": {
192
+ "name": "IBM RXN",
193
+ "env_var": "IBM_RXN_API_KEY",
194
+ "description": "AI-powered retrosynthesis (chemistry.retrosynthesis)",
195
+ "url": "https://rxn.res.ibm.com",
196
+ "free": True,
197
+ },
198
+ "api.lens_key": {
199
+ "name": "Lens.org",
200
+ "env_var": "LENS_API_KEY",
201
+ "description": "Patent search (literature.patent_search)",
202
+ "url": "https://www.lens.org/lens/user/subscriptions",
203
+ "free": True,
204
+ },
205
+ "notification.sendgrid_api_key": {
206
+ "name": "SendGrid",
207
+ "env_var": "SENDGRID_API_KEY",
208
+ "description": "Email sending (notification.send_email)",
209
+ "url": "https://sendgrid.com",
210
+ "free": True,
211
+ },
212
+ "compute.lambda_api_key": {
213
+ "name": "Lambda Labs",
214
+ "env_var": "LAMBDA_API_KEY",
215
+ "description": "GPU compute jobs (compute.submit_job)",
216
+ "url": "https://cloud.lambdalabs.com",
217
+ "free": False,
218
+ },
219
+ "compute.runpod_api_key": {
220
+ "name": "RunPod",
221
+ "env_var": "RUNPOD_API_KEY",
222
+ "description": "GPU compute jobs (compute.submit_job)",
223
+ "url": "https://www.runpod.io",
224
+ "free": False,
225
+ },
226
+ }
227
+
228
+
229
+ def _validate_config(config_dict: dict) -> list[str]:
230
+ """Validate a config dict and return a list of warning/error messages.
231
+
232
+ Checks:
233
+ - Type correctness (numeric, bool, string)
234
+ - Range validity (positive integers, minimums)
235
+ - Interdependency warnings (pharma + quality_gate_strict)
236
+ - Unknown keys (possible typos)
237
+ """
238
+ warnings: list[str] = []
239
+
240
+ # --- Unknown keys ---
241
+ known_keys = set(DEFAULTS.keys())
242
+ for key in config_dict:
243
+ if key not in known_keys:
244
+ warnings.append(f"Unknown config key '{key}' (possible typo)")
245
+
246
+ # --- Type checks ---
247
+ for key, value in config_dict.items():
248
+ if key not in DEFAULTS or value is None:
249
+ continue
250
+ default = DEFAULTS[key]
251
+ if default is None:
252
+ continue
253
+
254
+ expected_type = type(default)
255
+ if expected_type == bool:
256
+ if not isinstance(value, bool):
257
+ warnings.append(
258
+ f"Type error: '{key}' should be bool, got {type(value).__name__} ({value!r})"
259
+ )
260
+ elif expected_type == int:
261
+ if not isinstance(value, (int, float)):
262
+ warnings.append(
263
+ f"Type error: '{key}' should be int, got {type(value).__name__} ({value!r})"
264
+ )
265
+ elif expected_type == float:
266
+ if not isinstance(value, (int, float)):
267
+ warnings.append(
268
+ f"Type error: '{key}' should be float, got {type(value).__name__} ({value!r})"
269
+ )
270
+ elif expected_type == str:
271
+ if not isinstance(value, str):
272
+ warnings.append(
273
+ f"Type error: '{key}' should be str, got {type(value).__name__} ({value!r})"
274
+ )
275
+
276
+ # --- Range checks ---
277
+ def _check_positive_int(key: str, label: str):
278
+ val = config_dict.get(key)
279
+ if val is not None and isinstance(val, (int, float)) and val <= 0:
280
+ warnings.append(f"Range error: '{key}' ({label}) must be > 0, got {val}")
281
+
282
+ def _check_min(key: str, minimum: int, label: str):
283
+ val = config_dict.get(key)
284
+ if val is not None and isinstance(val, (int, float)) and val < minimum:
285
+ warnings.append(
286
+ f"Range error: '{key}' ({label}) must be >= {minimum}, got {val}"
287
+ )
288
+
289
+ _check_positive_int("agent.max_iterations", "max iterations")
290
+ _check_positive_int("agent.executor_max_retries", "executor max retries")
291
+ _check_positive_int("agent.executor_loop_limit", "executor loop limit")
292
+ _check_positive_int("agent.parallel_max_threads", "parallel max threads")
293
+ _check_min("agent.synthesis_max_tokens", 512, "synthesis max tokens")
294
+ _check_min("sandbox.timeout", 1, "sandbox timeout")
295
+
296
+ # --- Interdependency checks ---
297
+ profile = config_dict.get("agent.profile")
298
+ if profile == "pharma":
299
+ qg_strict = config_dict.get(
300
+ "agent.quality_gate_strict",
301
+ DEFAULTS.get("agent.quality_gate_strict"),
302
+ )
303
+ if qg_strict is False or qg_strict == 0:
304
+ warnings.append(
305
+ "Interdependency warning: profile is 'pharma' but "
306
+ "agent.quality_gate_strict is false (recommended: true)"
307
+ )
308
+
309
+ return warnings
310
+
311
+
312
+ class Config:
313
+ """ct configuration manager."""
314
+
315
+ def __init__(self, data: dict = None):
316
+ self._data = data or {}
317
+ self._env_loaded_keys: set[str] = set()
318
+
319
+ def __repr__(self) -> str:
320
+ """Safe repr that masks API keys and secrets."""
321
+ safe = {}
322
+ for k, v in self._data.items():
323
+ if ("api_key" in k or "secret" in k or k.startswith("api.")) and v:
324
+ safe[k] = str(v)[:4] + "..." if len(str(v)) > 4 else "***"
325
+ else:
326
+ safe[k] = v
327
+ return f"Config({safe})"
328
+
329
+ @classmethod
330
+ def load(cls) -> "Config":
331
+ """Load config from disk, creating defaults if needed."""
332
+ if CONFIG_FILE.exists():
333
+ try:
334
+ with open(CONFIG_FILE) as f:
335
+ data = json.load(f)
336
+ if not isinstance(data, dict):
337
+ logger.warning(
338
+ "Invalid config format in %s (expected JSON object), ignoring file",
339
+ CONFIG_FILE,
340
+ )
341
+ data = {}
342
+ except (json.JSONDecodeError, OSError) as exc:
343
+ logger.warning("Failed to read config file %s: %s", CONFIG_FILE, exc)
344
+ data = {}
345
+ else:
346
+ data = {}
347
+
348
+ # Migrate legacy global output dir default to workspace-local output dir.
349
+ legacy_output_dir = str(Path.home() / ".ct" / "outputs")
350
+ if data.get("sandbox.output_dir") == legacy_output_dir:
351
+ data["sandbox.output_dir"] = str(Path.cwd() / "outputs")
352
+
353
+ # Check environment variables
354
+ env_mappings = {
355
+ "ANTHROPIC_API_KEY": "llm.api_key",
356
+ "OPENAI_API_KEY": "llm.openai_api_key",
357
+ "CT_DATA_DIR": "data.base",
358
+ "CT_LLM_PROVIDER": "llm.provider",
359
+ "CT_LLM_MODEL": "llm.model",
360
+ "IBM_RXN_API_KEY": "api.ibm_rxn_key",
361
+ "LENS_API_KEY": "api.lens_key",
362
+ "SENDGRID_API_KEY": "notification.sendgrid_api_key",
363
+ "LAMBDA_API_KEY": "compute.lambda_api_key",
364
+ "RUNPOD_API_KEY": "compute.runpod_api_key",
365
+ "CT_DATA_ENDPOINT": "api.data_endpoint",
366
+ "CLUE_API_KEY": "api.clue_key",
367
+ }
368
+ for env_var, config_key in env_mappings.items():
369
+ val = os.environ.get(env_var)
370
+ if val and config_key not in data:
371
+ data[config_key] = val
372
+
373
+ cfg = cls(data)
374
+ # Track keys loaded from environment so they're masked in __repr__/logs
375
+ cfg._env_loaded_keys = {
376
+ config_key for env_var, config_key in env_mappings.items()
377
+ if os.environ.get(env_var) and config_key in data
378
+ }
379
+
380
+ # Run validation and log warnings (never crash)
381
+ issues = _validate_config(data)
382
+ for issue in issues:
383
+ logger.warning("Config validation: %s", issue)
384
+
385
+ return cfg
386
+
387
+ def validate(self) -> list[str]:
388
+ """Run schema validation on current config data. Returns list of issues."""
389
+ return _validate_config(self._data)
390
+
391
+ def save(self):
392
+ """Save config to disk."""
393
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
394
+ with open(CONFIG_FILE, "w") as f:
395
+ json.dump(self._data, f, indent=2)
396
+
397
+ def get(self, key: str, default: Any = None) -> Any:
398
+ """Get a config value, falling back to defaults."""
399
+ return self._data.get(key, DEFAULTS.get(key, default))
400
+
401
+ def set(self, key: str, value: Any):
402
+ """Set a config value."""
403
+ if key == "agent.profile":
404
+ profile = str(value).strip().lower()
405
+ if profile not in AGENT_PROFILE_PRESETS:
406
+ valid = ", ".join(sorted(AGENT_PROFILE_PRESETS.keys()))
407
+ raise ValueError(
408
+ f"Invalid agent.profile '{value}'. Valid profiles: {valid}"
409
+ )
410
+ for preset_key, preset_val in AGENT_PROFILE_PRESETS[profile].items():
411
+ self._data[preset_key] = preset_val
412
+ self._data["agent.profile"] = profile
413
+ return
414
+
415
+ # Type coercion
416
+ if key in DEFAULTS and DEFAULTS[key] is not None:
417
+ expected_type = type(DEFAULTS[key])
418
+ if expected_type == bool:
419
+ value = value.lower() in ("true", "1", "yes") if isinstance(value, str) else bool(value)
420
+ elif expected_type == float:
421
+ value = float(value)
422
+ elif expected_type == int:
423
+ value = int(value)
424
+
425
+ self._data[key] = value
426
+
427
+ def llm_api_key(self, provider: Optional[str] = None) -> Optional[str]:
428
+ """Get the best API key for the selected provider."""
429
+ provider = (provider or self.get("llm.provider", "anthropic")).lower()
430
+ if provider == "openai":
431
+ return self.get("llm.openai_api_key") or self.get("llm.api_key")
432
+ return self.get("llm.api_key")
433
+
434
+ def llm_preflight_issue(self) -> Optional[str]:
435
+ """Return a human-readable LLM config issue, or None when ready."""
436
+ provider_raw = self.get("llm.provider", "anthropic")
437
+ provider = str(provider_raw or "").strip().lower()
438
+ if not provider:
439
+ return "llm.provider is empty. Set it with: ct config set llm.provider anthropic"
440
+
441
+ if provider not in VALID_LLM_PROVIDERS:
442
+ valid = ", ".join(sorted(VALID_LLM_PROVIDERS))
443
+ return (
444
+ f"Unsupported llm.provider '{provider}'. "
445
+ f"Valid providers: {valid}. Set it with: ct config set llm.provider <provider>"
446
+ )
447
+
448
+ if provider in {"local", "gluelm"}:
449
+ if not self.get("llm.model"):
450
+ return (
451
+ f"llm.model is required for provider '{provider}'. "
452
+ "Set it with: ct config set llm.model <model-id-or-path>"
453
+ )
454
+ return None
455
+
456
+ if self.llm_api_key(provider):
457
+ return None
458
+
459
+ if provider == "openai":
460
+ return (
461
+ "OpenAI API key not configured. Set OPENAI_API_KEY or run:\n"
462
+ " ct config set llm.openai_api_key <key>"
463
+ )
464
+
465
+ return (
466
+ "Anthropic API key not configured. Set ANTHROPIC_API_KEY or run:\n"
467
+ " ct config set llm.api_key <key>"
468
+ )
469
+
470
+ def keys_table(self) -> Table:
471
+ """Render API key status as a rich table."""
472
+ table = Table(title="API Keys", caption="Set: ct config set <key> <value> | Or: export ENV_VAR=<value>")
473
+ table.add_column("Service", style="bold")
474
+ table.add_column("Status")
475
+ table.add_column("Unlocks", style="dim")
476
+ table.add_column("Config Key", style="cyan dim")
477
+ table.add_column("Sign Up", style="dim")
478
+
479
+ for config_key, info in API_KEYS.items():
480
+ val = self.get(config_key)
481
+ if val:
482
+ status = "[green]configured[/green]"
483
+ else:
484
+ status = "[red]not set[/red]"
485
+
486
+ free_tag = " (free)" if info["free"] else ""
487
+ table.add_row(
488
+ info["name"],
489
+ status,
490
+ info["description"],
491
+ config_key,
492
+ info["url"] + free_tag,
493
+ )
494
+
495
+ return table
496
+
497
+ def to_table(self) -> Table:
498
+ """Render config as a rich table."""
499
+ table = Table(title="ct Configuration")
500
+ table.add_column("Key", style="cyan")
501
+ table.add_column("Value", style="green")
502
+ table.add_column("Source", style="dim")
503
+
504
+ all_keys = sorted(set(list(DEFAULTS.keys()) + list(self._data.keys())))
505
+ for key in all_keys:
506
+ if key in self._data:
507
+ val = self._data[key]
508
+ source = "config"
509
+ elif key in DEFAULTS:
510
+ val = DEFAULTS[key]
511
+ source = "default"
512
+ else:
513
+ continue
514
+
515
+ # Mask sensitive values (API keys, secrets)
516
+ display_val = str(val)
517
+ is_sensitive = "api_key" in key or "secret" in key or key.startswith("api.")
518
+ if is_sensitive and val and len(str(val)) > 8:
519
+ display_val = str(val)[:4] + "..." + str(val)[-4:]
520
+
521
+ table.add_row(key, display_val, source)
522
+
523
+ return table