devscontext 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.
devscontext/cli.py ADDED
@@ -0,0 +1,727 @@
1
+ """CLI entry point for DevsContext.
2
+
3
+ This module provides the command-line interface for DevsContext,
4
+ including commands for initialization, testing, and running the server.
5
+
6
+ Commands:
7
+ init: Create configuration file interactively
8
+ test: Test connection to configured adapters
9
+ serve: Start the MCP server (default)
10
+
11
+ Example:
12
+ devscontext init
13
+ devscontext test --task PROJ-123
14
+ devscontext serve
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import click
25
+
26
+ from devscontext import __version__
27
+
28
+
29
+ def _success(msg: str) -> str:
30
+ """Format success message with green checkmark."""
31
+ return click.style("✓", fg="green") + " " + msg
32
+
33
+
34
+ def _error(msg: str) -> str:
35
+ """Format error message with red X."""
36
+ return click.style("✗", fg="red") + " " + msg
37
+
38
+
39
+ def _info(msg: str) -> str:
40
+ """Format info message with blue arrow."""
41
+ return click.style("→", fg="blue") + " " + msg
42
+
43
+
44
+ @click.group(invoke_without_command=True)
45
+ @click.version_option(version=__version__, prog_name="devscontext")
46
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
47
+ @click.pass_context
48
+ def cli(ctx: click.Context, verbose: bool) -> None:
49
+ """DevsContext - MCP server for AI coding context.
50
+
51
+ Provides synthesized engineering context from Jira, meeting transcripts,
52
+ and local documentation to AI coding assistants.
53
+ """
54
+ ctx.ensure_object(dict)
55
+ ctx.obj["verbose"] = verbose
56
+
57
+ if ctx.invoked_subcommand is None:
58
+ ctx.invoke(serve)
59
+
60
+
61
+ @cli.command()
62
+ def init() -> None:
63
+ """Create .devscontext.yaml configuration interactively."""
64
+ config_path = Path(".devscontext.yaml")
65
+ gitignore_path = Path(".gitignore")
66
+
67
+ if config_path.exists():
68
+ click.echo(f"Config file already exists: {config_path}")
69
+ if not click.confirm("Overwrite?", default=False):
70
+ click.echo("Aborted.")
71
+ return
72
+
73
+ click.echo()
74
+ click.echo(click.style("DevsContext Setup", bold=True))
75
+ click.echo()
76
+
77
+ # Jira configuration
78
+ jira_enabled = click.confirm("Configure Jira?", default=True)
79
+ jira_url = ""
80
+ if jira_enabled:
81
+ jira_url = click.prompt(
82
+ " Jira URL",
83
+ default="https://your-company.atlassian.net",
84
+ )
85
+ click.echo(" " + _info("Set JIRA_EMAIL and JIRA_API_TOKEN environment variables"))
86
+
87
+ # Fireflies configuration
88
+ click.echo()
89
+ fireflies_enabled = click.confirm("Configure Fireflies (meeting transcripts)?", default=False)
90
+ if fireflies_enabled:
91
+ click.echo(" " + _info("Set FIREFLIES_API_KEY environment variable"))
92
+
93
+ # Local docs configuration
94
+ click.echo()
95
+ docs_enabled = click.confirm("Configure local docs?", default=True)
96
+ docs_paths: list[str] = []
97
+ if docs_enabled:
98
+ default_paths = "./docs"
99
+ if Path("CLAUDE.md").exists():
100
+ default_paths = "./docs, ./CLAUDE.md"
101
+ paths_input = click.prompt(" Doc paths (comma-separated)", default=default_paths)
102
+ docs_paths = [p.strip() for p in paths_input.split(",") if p.strip()]
103
+
104
+ # Build config
105
+ config_lines = [
106
+ "# DevsContext Configuration",
107
+ "",
108
+ "adapters:",
109
+ " jira:",
110
+ f" enabled: {str(jira_enabled).lower()}",
111
+ ]
112
+
113
+ if jira_enabled:
114
+ config_lines.extend(
115
+ [
116
+ f' base_url: "{jira_url}"',
117
+ ' email: "${JIRA_EMAIL}"',
118
+ ' api_token: "${JIRA_API_TOKEN}"',
119
+ ]
120
+ )
121
+
122
+ config_lines.extend(
123
+ [
124
+ "",
125
+ " fireflies:",
126
+ f" enabled: {str(fireflies_enabled).lower()}",
127
+ ]
128
+ )
129
+
130
+ if fireflies_enabled:
131
+ config_lines.append(' api_key: "${FIREFLIES_API_KEY}"')
132
+
133
+ config_lines.extend(
134
+ [
135
+ "",
136
+ " local_docs:",
137
+ f" enabled: {str(docs_enabled).lower()}",
138
+ ]
139
+ )
140
+
141
+ if docs_enabled and docs_paths:
142
+ config_lines.append(" paths:")
143
+ for path in docs_paths:
144
+ config_lines.append(f' - "{path}"')
145
+
146
+ config_lines.extend(
147
+ [
148
+ "",
149
+ "synthesis:",
150
+ ' provider: "anthropic"',
151
+ ' model: "claude-3-haiku-20240307"',
152
+ "",
153
+ "cache:",
154
+ " ttl_seconds: 300",
155
+ " max_size: 100",
156
+ "",
157
+ ]
158
+ )
159
+
160
+ config_content = "\n".join(config_lines)
161
+ config_path.write_text(config_content)
162
+
163
+ # Add to .gitignore if not already there
164
+ gitignore_updated = False
165
+ if gitignore_path.exists():
166
+ gitignore_content = gitignore_path.read_text()
167
+ if ".devscontext.yaml" not in gitignore_content:
168
+ with gitignore_path.open("a") as f:
169
+ if not gitignore_content.endswith("\n"):
170
+ f.write("\n")
171
+ f.write("\n# DevsContext config (contains env var references)\n")
172
+ f.write(".devscontext.yaml\n")
173
+ gitignore_updated = True
174
+ else:
175
+ gitignore_path.write_text("# DevsContext config\n.devscontext.yaml\n")
176
+ gitignore_updated = True
177
+
178
+ # Success message
179
+ click.echo()
180
+ click.echo(_success(f"Created {config_path}"))
181
+ if gitignore_updated:
182
+ click.echo(_success("Added .devscontext.yaml to .gitignore"))
183
+
184
+ click.echo()
185
+ click.echo(click.style("Next steps:", bold=True))
186
+ if jira_enabled:
187
+ click.echo(" export JIRA_EMAIL='your-email@company.com'")
188
+ click.echo(" export JIRA_API_TOKEN='your-api-token'")
189
+ if fireflies_enabled:
190
+ click.echo(" export FIREFLIES_API_KEY='your-api-key'")
191
+ click.echo(" export ANTHROPIC_API_KEY='your-api-key'")
192
+ click.echo()
193
+ click.echo(" devscontext test --task YOUR-123")
194
+
195
+
196
+ @cli.command()
197
+ @click.option("--task", "-t", default=None, help="Jira ticket ID (e.g., PROJ-123)")
198
+ @click.pass_context
199
+ def test(ctx: click.Context, task: str | None) -> None:
200
+ """Test connection to configured adapters."""
201
+ import asyncio
202
+
203
+ from devscontext.config import load_devscontext_config
204
+ from devscontext.core import DevsContextCore
205
+
206
+ verbose = ctx.obj.get("verbose", False)
207
+
208
+ try:
209
+ config = load_devscontext_config()
210
+ except FileNotFoundError:
211
+ click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
212
+ sys.exit(1)
213
+
214
+ click.echo()
215
+ click.echo(click.style("Connection Status", bold=True))
216
+ click.echo()
217
+
218
+ core = DevsContextCore(config)
219
+
220
+ async def run_health_checks() -> dict[str, bool]:
221
+ return await core.health_check()
222
+
223
+ results = asyncio.run(run_health_checks())
224
+ healthy_count = sum(1 for h in results.values() if h)
225
+
226
+ for adapter, healthy in results.items():
227
+ if healthy:
228
+ click.echo(" " + _success(adapter))
229
+ else:
230
+ click.echo(" " + _error(f"{adapter} (check credentials)"))
231
+
232
+ click.echo()
233
+
234
+ if not task:
235
+ click.echo(_info("Use --task PROJ-123 to test fetching context"))
236
+ return
237
+
238
+ if healthy_count == 0:
239
+ click.echo(_error("No healthy adapters. Fix connections before testing."))
240
+ sys.exit(1)
241
+
242
+ click.echo(click.style(f"Fetching context for {task}...", bold=True))
243
+ click.echo()
244
+
245
+ start_time = time.monotonic()
246
+
247
+ async def fetch_context() -> tuple[str, list[str]]:
248
+ result = await core.get_task_context(task)
249
+ return result.synthesized, result.sources_used
250
+
251
+ try:
252
+ output, sources = asyncio.run(fetch_context())
253
+ duration = time.monotonic() - start_time
254
+
255
+ click.echo(output)
256
+ click.echo()
257
+ click.echo(
258
+ click.style(
259
+ f"Fetched from {len(sources)} source(s) and synthesized in {duration:.1f}s",
260
+ fg="cyan",
261
+ )
262
+ )
263
+ except Exception as e:
264
+ if verbose:
265
+ import traceback
266
+
267
+ click.echo(traceback.format_exc(), err=True)
268
+ click.echo(_error(f"Failed: {e}"), err=True)
269
+ sys.exit(1)
270
+
271
+
272
+ @cli.command()
273
+ @click.pass_context
274
+ def serve(ctx: click.Context) -> None:
275
+ """Start the MCP server (stdio transport)."""
276
+
277
+ # Print startup message to stderr (stdout is for MCP protocol)
278
+ click.echo(
279
+ click.style("DevsContext", bold=True) + " MCP server running",
280
+ err=True,
281
+ )
282
+ click.echo(
283
+ "Tools: get_task_context, search_context, get_standards",
284
+ err=True,
285
+ )
286
+ click.echo(err=True)
287
+
288
+ from devscontext.server import main as server_main
289
+
290
+ server_main()
291
+
292
+
293
+ # =============================================================================
294
+ # RAG INDEXING COMMAND
295
+ # =============================================================================
296
+
297
+
298
+ @cli.command("index-docs")
299
+ @click.option("--rebuild", is_flag=True, help="Clear and rebuild the entire index")
300
+ @click.option("--status", "show_status", is_flag=True, help="Show index statistics only")
301
+ @click.pass_context
302
+ def index_docs(ctx: click.Context, rebuild: bool, show_status: bool) -> None:
303
+ """Build embedding index for local documentation.
304
+
305
+ Creates vector embeddings for all markdown documents in the configured
306
+ doc paths, enabling semantic search for better doc matching.
307
+
308
+ This command requires RAG to be configured in .devscontext.yaml:
309
+
310
+ \b
311
+ sources:
312
+ docs:
313
+ rag:
314
+ enabled: true
315
+ embedding_provider: local
316
+ embedding_model: all-MiniLM-L6-v2
317
+
318
+ Requires: pip install devscontext[rag]
319
+ """
320
+ import asyncio
321
+
322
+ from devscontext.adapters.local_docs import LocalDocsAdapter
323
+ from devscontext.config import load_devscontext_config
324
+
325
+ verbose = ctx.obj.get("verbose", False)
326
+
327
+ try:
328
+ config = load_devscontext_config()
329
+ except FileNotFoundError:
330
+ click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
331
+ sys.exit(1)
332
+
333
+ if not config.sources.docs.rag:
334
+ click.echo(_error("RAG not configured in .devscontext.yaml"))
335
+ click.echo()
336
+ click.echo("Add the following to your config:")
337
+ click.echo()
338
+ click.echo(" sources:")
339
+ click.echo(" docs:")
340
+ click.echo(" rag:")
341
+ click.echo(" enabled: true")
342
+ click.echo(" embedding_provider: local")
343
+ sys.exit(1)
344
+
345
+ # Check if RAG dependencies are available
346
+ try:
347
+ from devscontext.rag import is_rag_available
348
+
349
+ if not is_rag_available():
350
+ click.echo(_error("RAG dependencies not installed"))
351
+ click.echo()
352
+ click.echo("Install with: pip install devscontext[rag]")
353
+ sys.exit(1)
354
+ except ImportError:
355
+ click.echo(_error("RAG module not available"))
356
+ sys.exit(1)
357
+
358
+ # Status only mode
359
+ if show_status:
360
+ from devscontext.rag import DocumentIndex
361
+
362
+ index = DocumentIndex(config.sources.docs.rag.index_path)
363
+
364
+ click.echo()
365
+ click.echo(click.style("RAG Index Status", bold=True))
366
+ click.echo()
367
+
368
+ if not index.exists():
369
+ click.echo(_error("Index does not exist"))
370
+ click.echo(_info("Run 'devscontext index-docs' to build it"))
371
+ return
372
+
373
+ index.load()
374
+ stats = index.get_stats()
375
+
376
+ click.echo(f" Index path: {stats['index_path']}")
377
+ click.echo(f" Model: {stats['model']}")
378
+ click.echo(f" Dimension: {stats['dimension']}")
379
+ click.echo(f" Sections: {stats['section_count']}")
380
+ if stats["indexed_at"]:
381
+ click.echo(f" Indexed at: {stats['indexed_at']}")
382
+
383
+ if stats.get("doc_types"):
384
+ click.echo()
385
+ click.echo(" Document types:")
386
+ for doc_type, count in stats["doc_types"].items():
387
+ click.echo(f" {doc_type}: {count}")
388
+
389
+ return
390
+
391
+ # Build/rebuild index
392
+ click.echo()
393
+ click.echo(click.style("Building RAG Index", bold=True))
394
+ click.echo()
395
+
396
+ if rebuild:
397
+ click.echo(_info("Rebuild mode - clearing existing index"))
398
+
399
+ click.echo(_info(f"Model: {config.sources.docs.rag.embedding_model}"))
400
+ click.echo(_info(f"Doc paths: {', '.join(config.sources.docs.paths)}"))
401
+ click.echo()
402
+
403
+ adapter = LocalDocsAdapter(config.sources.docs)
404
+
405
+ async def build_index() -> dict[str, Any]:
406
+ return await adapter.index_documents(rebuild=rebuild)
407
+
408
+ try:
409
+ start_time = time.monotonic()
410
+ result = asyncio.run(build_index())
411
+ duration = time.monotonic() - start_time
412
+
413
+ if result["status"] == "no_docs":
414
+ click.echo(_error("No documents found in configured paths"))
415
+ sys.exit(1)
416
+
417
+ click.echo(_success(f"Indexed {result['sections_indexed']} sections in {duration:.1f}s"))
418
+ click.echo()
419
+ click.echo(f" Files scanned: {result['files_scanned']}")
420
+ click.echo(f" Dimension: {result['dimension']}")
421
+ click.echo(f" Index path: {result['index_path']}")
422
+
423
+ except ImportError as e:
424
+ click.echo(_error(f"Missing dependency: {e}"))
425
+ click.echo()
426
+ click.echo("Install with: pip install devscontext[rag]")
427
+ sys.exit(1)
428
+ except Exception as e:
429
+ if verbose:
430
+ import traceback
431
+
432
+ click.echo(traceback.format_exc(), err=True)
433
+ click.echo(_error(f"Indexing failed: {e}"), err=True)
434
+ sys.exit(1)
435
+
436
+
437
+ # =============================================================================
438
+ # AGENT COMMANDS
439
+ # =============================================================================
440
+
441
+
442
+ @cli.group()
443
+ @click.pass_context
444
+ def agent(ctx: click.Context) -> None:
445
+ """Manage the pre-processing agent.
446
+
447
+ The agent watches Jira for tickets in a target status (e.g., "Ready for
448
+ Development") and pre-builds rich context before anyone picks them up.
449
+
450
+ Commands:
451
+ start: Run polling agent in foreground
452
+ run-once: Single poll cycle, then exit
453
+ status: Show pre-built context stats
454
+ process: Manually process a specific ticket
455
+ """
456
+ pass
457
+
458
+
459
+ @agent.command()
460
+ @click.pass_context
461
+ def start(ctx: click.Context) -> None:
462
+ """Start the polling agent in foreground.
463
+
464
+ Polls Jira periodically for tickets in the target status and processes
465
+ them through the preprocessing pipeline. Press Ctrl+C to stop.
466
+ """
467
+ import asyncio
468
+ import signal
469
+
470
+ from devscontext.agents import JiraWatcher, PreprocessingPipeline
471
+ from devscontext.config import load_devscontext_config
472
+ from devscontext.storage import PrebuiltContextStorage
473
+
474
+ verbose = ctx.obj.get("verbose", False)
475
+
476
+ try:
477
+ config = load_devscontext_config()
478
+ except FileNotFoundError:
479
+ click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
480
+ sys.exit(1)
481
+
482
+ if not config.agents.preprocessor.enabled:
483
+ click.echo(_error("Preprocessor agent not enabled in config."))
484
+ click.echo(_info("Set agents.preprocessor.enabled: true in .devscontext.yaml"))
485
+ sys.exit(1)
486
+
487
+ if not config.sources.jira.enabled:
488
+ click.echo(_error("Jira adapter not enabled. Agent requires Jira."))
489
+ sys.exit(1)
490
+
491
+ click.echo()
492
+ click.echo(click.style("DevsContext Agent", bold=True))
493
+ click.echo()
494
+ click.echo(
495
+ _info(f"Polling every {config.agents.preprocessor.trigger.poll_interval_minutes} minutes")
496
+ )
497
+ click.echo(_info(f"Watching for status: {config.agents.preprocessor.jira_status}"))
498
+ click.echo(_info(f"Project(s): {config.agents.preprocessor.jira_project}"))
499
+ click.echo()
500
+
501
+ async def run_agent() -> None:
502
+ storage = PrebuiltContextStorage(config.storage.path)
503
+ await storage.initialize()
504
+
505
+ pipeline = PreprocessingPipeline(config, storage)
506
+ watcher = JiraWatcher(config, pipeline)
507
+
508
+ # Handle Ctrl+C gracefully
509
+ loop = asyncio.get_event_loop()
510
+ for sig in (signal.SIGINT, signal.SIGTERM):
511
+ loop.add_signal_handler(sig, watcher.stop)
512
+
513
+ try:
514
+ click.echo(_success("Agent started. Press Ctrl+C to stop."))
515
+ click.echo()
516
+ await watcher.run()
517
+ finally:
518
+ await watcher.close()
519
+ await pipeline.close()
520
+ await storage.close()
521
+
522
+ try:
523
+ asyncio.run(run_agent())
524
+ except Exception as e:
525
+ if verbose:
526
+ import traceback
527
+
528
+ click.echo(traceback.format_exc(), err=True)
529
+ click.echo(_error(f"Agent error: {e}"), err=True)
530
+ sys.exit(1)
531
+
532
+ click.echo()
533
+ click.echo(_success("Agent stopped."))
534
+
535
+
536
+ @agent.command("run-once")
537
+ @click.pass_context
538
+ def run_once(ctx: click.Context) -> None:
539
+ """Single run: check for ready tickets, process, exit.
540
+
541
+ Useful for cron jobs or CI pipelines. Performs one poll cycle,
542
+ processes any new tickets found, and exits.
543
+ """
544
+ import asyncio
545
+
546
+ from devscontext.agents import JiraWatcher, PreprocessingPipeline
547
+ from devscontext.config import load_devscontext_config
548
+ from devscontext.storage import PrebuiltContextStorage
549
+
550
+ verbose = ctx.obj.get("verbose", False)
551
+
552
+ try:
553
+ config = load_devscontext_config()
554
+ except FileNotFoundError:
555
+ click.echo(_error("No .devscontext.yaml found."))
556
+ sys.exit(1)
557
+
558
+ if not config.agents.preprocessor.enabled:
559
+ click.echo(_error("Preprocessor agent not enabled in config."))
560
+ sys.exit(1)
561
+
562
+ click.echo(click.style("DevsContext Agent - Single Run", bold=True))
563
+ click.echo()
564
+
565
+ async def run_single() -> int:
566
+ storage = PrebuiltContextStorage(config.storage.path)
567
+ await storage.initialize()
568
+
569
+ pipeline = PreprocessingPipeline(config, storage)
570
+ watcher = JiraWatcher(config, pipeline)
571
+
572
+ try:
573
+ processed = await watcher.run_once()
574
+ return processed
575
+ finally:
576
+ await watcher.close()
577
+ await pipeline.close()
578
+ await storage.close()
579
+
580
+ try:
581
+ processed = asyncio.run(run_single())
582
+ click.echo()
583
+ click.echo(_success(f"Processed {processed} ticket(s)."))
584
+ except Exception as e:
585
+ if verbose:
586
+ import traceback
587
+
588
+ click.echo(traceback.format_exc(), err=True)
589
+ click.echo(_error(f"Error: {e}"), err=True)
590
+ sys.exit(1)
591
+
592
+
593
+ @agent.command()
594
+ @click.pass_context
595
+ def status(ctx: click.Context) -> None:
596
+ """Show pre-built context stats.
597
+
598
+ Displays statistics about stored pre-built context including
599
+ total count, active count, average quality, and last build time.
600
+ """
601
+ import asyncio
602
+
603
+ from devscontext.config import load_devscontext_config
604
+ from devscontext.storage import PrebuiltContextStorage
605
+
606
+ try:
607
+ config = load_devscontext_config()
608
+ except FileNotFoundError:
609
+ click.echo(_error("No .devscontext.yaml found."))
610
+ sys.exit(1)
611
+
612
+ async def get_status() -> dict[str, Any]:
613
+ storage = PrebuiltContextStorage(config.storage.path)
614
+ try:
615
+ await storage.initialize()
616
+ stats = await storage.get_stats()
617
+ return stats
618
+ finally:
619
+ await storage.close()
620
+
621
+ try:
622
+ stats = asyncio.run(get_status())
623
+ except Exception as e:
624
+ click.echo(_error(f"Could not read storage: {e}"))
625
+ sys.exit(1)
626
+
627
+ click.echo()
628
+ click.echo(click.style("Pre-built Context Storage", bold=True))
629
+ click.echo()
630
+ click.echo(f" Total contexts: {stats['total']}")
631
+ click.echo(f" Active (not expired): {stats['active']}")
632
+ click.echo(f" Expired: {stats['expired']}")
633
+
634
+ if stats["avg_quality"] > 0:
635
+ click.echo(f" Average quality: {stats['avg_quality']:.1%}")
636
+
637
+ if stats["last_build"]:
638
+ click.echo(f" Last build: {stats['last_build']}")
639
+ else:
640
+ click.echo(" Last build: (none)")
641
+
642
+ click.echo()
643
+ click.echo(_info(f"Storage path: {config.storage.path}"))
644
+
645
+
646
+ @agent.command()
647
+ @click.argument("task_id")
648
+ @click.pass_context
649
+ def process(ctx: click.Context, task_id: str) -> None:
650
+ """Manually trigger pre-processing for a specific ticket.
651
+
652
+ TASK_ID is the Jira ticket ID (e.g., PROJ-123).
653
+
654
+ This bypasses the watcher and immediately processes the specified
655
+ ticket through the full preprocessing pipeline.
656
+ """
657
+ import asyncio
658
+
659
+ from devscontext.agents import PreprocessingPipeline
660
+ from devscontext.config import load_devscontext_config
661
+ from devscontext.storage import PrebuiltContextStorage
662
+
663
+ verbose = ctx.obj.get("verbose", False)
664
+
665
+ try:
666
+ config = load_devscontext_config()
667
+ except FileNotFoundError:
668
+ click.echo(_error("No .devscontext.yaml found."))
669
+ sys.exit(1)
670
+
671
+ click.echo()
672
+ click.echo(click.style(f"Processing {task_id}", bold=True))
673
+ click.echo()
674
+
675
+ start_time = time.monotonic()
676
+
677
+ async def process_ticket() -> dict[str, Any]:
678
+ storage = PrebuiltContextStorage(config.storage.path)
679
+ await storage.initialize()
680
+
681
+ pipeline = PreprocessingPipeline(config, storage)
682
+
683
+ try:
684
+ context = await pipeline.process(task_id)
685
+ return {
686
+ "quality_score": context.context_quality_score,
687
+ "gaps": context.gaps,
688
+ "sources_count": len(context.sources_used),
689
+ }
690
+ finally:
691
+ await pipeline.close()
692
+ await storage.close()
693
+
694
+ try:
695
+ result = asyncio.run(process_ticket())
696
+ duration = time.monotonic() - start_time
697
+
698
+ click.echo(_success(f"Processed in {duration:.1f}s"))
699
+ click.echo()
700
+ click.echo(f" Quality score: {result['quality_score']:.1%}")
701
+ click.echo(f" Sources used: {result['sources_count']}")
702
+
703
+ if result["gaps"]:
704
+ click.echo()
705
+ click.echo(click.style("Identified gaps:", fg="yellow"))
706
+ for gap in result["gaps"]:
707
+ click.echo(f" - {gap}")
708
+ else:
709
+ click.echo()
710
+ click.echo(_success("No gaps identified - context is complete!"))
711
+
712
+ except Exception as e:
713
+ if verbose:
714
+ import traceback
715
+
716
+ click.echo(traceback.format_exc(), err=True)
717
+ click.echo(_error(f"Failed to process: {e}"), err=True)
718
+ sys.exit(1)
719
+
720
+
721
+ def main() -> None:
722
+ """Main entry point for the CLI."""
723
+ cli()
724
+
725
+
726
+ if __name__ == "__main__":
727
+ main()