agentic-threat-hunting-framework 0.2.3__py3-none-any.whl → 0.3.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 (43) hide show
  1. {agentic_threat_hunting_framework-0.2.3.dist-info → agentic_threat_hunting_framework-0.3.0.dist-info}/METADATA +38 -40
  2. agentic_threat_hunting_framework-0.3.0.dist-info/RECORD +51 -0
  3. athf/__version__.py +1 -1
  4. athf/cli.py +7 -2
  5. athf/commands/__init__.py +4 -0
  6. athf/commands/agent.py +452 -0
  7. athf/commands/context.py +6 -9
  8. athf/commands/env.py +2 -2
  9. athf/commands/hunt.py +3 -3
  10. athf/commands/init.py +45 -0
  11. athf/commands/research.py +530 -0
  12. athf/commands/similar.py +5 -5
  13. athf/core/research_manager.py +419 -0
  14. athf/core/web_search.py +340 -0
  15. athf/data/__init__.py +19 -0
  16. athf/data/docs/CHANGELOG.md +147 -0
  17. athf/data/docs/CLI_REFERENCE.md +1797 -0
  18. athf/data/docs/INSTALL.md +594 -0
  19. athf/data/docs/README.md +31 -0
  20. athf/data/docs/environment.md +256 -0
  21. athf/data/docs/getting-started.md +419 -0
  22. athf/data/docs/level4-agentic-workflows.md +480 -0
  23. athf/data/docs/lock-pattern.md +149 -0
  24. athf/data/docs/maturity-model.md +400 -0
  25. athf/data/docs/why-athf.md +44 -0
  26. athf/data/hunts/FORMAT_GUIDELINES.md +507 -0
  27. athf/data/hunts/H-0001.md +453 -0
  28. athf/data/hunts/H-0002.md +436 -0
  29. athf/data/hunts/H-0003.md +546 -0
  30. athf/data/hunts/README.md +231 -0
  31. athf/data/integrations/MCP_CATALOG.md +45 -0
  32. athf/data/integrations/README.md +129 -0
  33. athf/data/integrations/quickstart/splunk.md +162 -0
  34. athf/data/knowledge/hunting-knowledge.md +2375 -0
  35. athf/data/prompts/README.md +172 -0
  36. athf/data/prompts/ai-workflow.md +581 -0
  37. athf/data/prompts/basic-prompts.md +316 -0
  38. athf/data/templates/HUNT_LOCK.md +228 -0
  39. agentic_threat_hunting_framework-0.2.3.dist-info/RECORD +0 -23
  40. {agentic_threat_hunting_framework-0.2.3.dist-info → agentic_threat_hunting_framework-0.3.0.dist-info}/WHEEL +0 -0
  41. {agentic_threat_hunting_framework-0.2.3.dist-info → agentic_threat_hunting_framework-0.3.0.dist-info}/entry_points.txt +0 -0
  42. {agentic_threat_hunting_framework-0.2.3.dist-info → agentic_threat_hunting_framework-0.3.0.dist-info}/licenses/LICENSE +0 -0
  43. {agentic_threat_hunting_framework-0.2.3.dist-info → agentic_threat_hunting_framework-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,530 @@
1
+ """Research management commands - thorough pre-hunt investigation."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+ from rich.table import Table
12
+
13
+ from athf.agents.llm.hunt_researcher import ResearchOutput
14
+
15
+ console = Console()
16
+
17
+ RESEARCH_EPILOG = """
18
+ \b
19
+ Examples:
20
+ # Start new research (15-20 minute deep dive)
21
+ athf research new --topic "LSASS memory dumping"
22
+
23
+ # Quick research (5 minutes)
24
+ athf research new --topic "Pass-the-Hash" --depth basic
25
+
26
+ # Research with specific technique
27
+ athf research new --topic "Credential Access" --technique T1003.001
28
+
29
+ # List all research
30
+ athf research list
31
+
32
+ # View specific research
33
+ athf research view R-0001
34
+
35
+ # Create hunt from research
36
+ athf hunt new --research R-0001
37
+
38
+ \b
39
+ Research Skills (5-skill methodology):
40
+ 1. System Research - How does this technology normally work?
41
+ 2. Adversary Tradecraft - How do adversaries abuse it? (web search)
42
+ 3. Telemetry Mapping - What OCSF fields capture this?
43
+ 4. Related Work - What past hunts are relevant?
44
+ 5. Research Synthesis - Key findings, gaps, focus areas
45
+ """
46
+
47
+
48
+ @click.group(epilog=RESEARCH_EPILOG)
49
+ def research() -> None:
50
+ """Conduct thorough research before hunting.
51
+
52
+ \b
53
+ The research command helps you:
54
+ * Understand normal system behavior
55
+ * Discover adversary tradecraft via web search
56
+ * Map attacks to available telemetry
57
+ * Find related past work
58
+ * Synthesize actionable insights
59
+
60
+ \b
61
+ Research creates R-XXXX documents that can be linked to hunts.
62
+ """
63
+ pass
64
+
65
+
66
+ @research.command()
67
+ @click.option("--topic", required=True, help="Research topic (e.g., 'LSASS dumping')")
68
+ @click.option("--technique", help="MITRE ATT&CK technique (e.g., T1003.001)")
69
+ @click.option(
70
+ "--depth",
71
+ type=click.Choice(["basic", "advanced"]),
72
+ default="advanced",
73
+ help="Research depth: basic (5 min) or advanced (15-20 min)",
74
+ )
75
+ @click.option("--no-web-search", is_flag=True, help="Skip web search (offline mode)")
76
+ @click.option("--output", "output_format", type=click.Choice(["table", "json"]), default="table")
77
+ def new(
78
+ topic: str,
79
+ technique: Optional[str],
80
+ depth: str,
81
+ no_web_search: bool,
82
+ output_format: str,
83
+ ) -> None:
84
+ """Create new research document with thorough analysis.
85
+
86
+ \b
87
+ Performs 5-skill research methodology:
88
+ 1. System Research - How does this normally work?
89
+ 2. Adversary Tradecraft - Attack techniques (web search)
90
+ 3. Telemetry Mapping - OCSF field mapping
91
+ 4. Related Work - Past hunts correlation
92
+ 5. Synthesis - Key findings and gaps
93
+
94
+ \b
95
+ Examples:
96
+ athf research new --topic "LSASS dumping"
97
+ athf research new --topic "Pass-the-Hash" --technique T1003.002 --depth basic
98
+ """
99
+ from athf.agents.llm.hunt_researcher import HuntResearcherAgent, ResearchInput
100
+ from athf.core.research_manager import ResearchManager
101
+
102
+ # Get next research ID
103
+ manager = ResearchManager()
104
+ research_id = manager.get_next_research_id()
105
+
106
+ console.print(f"\n[bold cyan]Starting Research: {research_id}[/bold cyan]")
107
+ console.print(f"[bold]Topic:[/bold] {topic}")
108
+ console.print(f"[bold]Depth:[/bold] {depth} ({'~5 min' if depth == 'basic' else '~15-20 min'})")
109
+ if technique:
110
+ console.print(f"[bold]Technique:[/bold] {technique}")
111
+ console.print()
112
+
113
+ # Initialize agent
114
+ agent = HuntResearcherAgent(llm_enabled=True)
115
+
116
+ # Show progress for each skill
117
+ with Progress(
118
+ SpinnerColumn(),
119
+ TextColumn("[progress.description]{task.description}"),
120
+ console=console,
121
+ transient=True,
122
+ ) as progress:
123
+ progress.add_task("Conducting research...", total=None)
124
+
125
+ # Execute research
126
+ result = agent.execute(
127
+ ResearchInput(
128
+ topic=topic,
129
+ mitre_technique=technique,
130
+ depth=depth,
131
+ include_past_hunts=True,
132
+ include_telemetry_mapping=True,
133
+ web_search_enabled=not no_web_search,
134
+ )
135
+ )
136
+
137
+ if not result.success:
138
+ console.print(f"[red]Research failed: {result.error}[/red]")
139
+ raise click.Abort()
140
+
141
+ output = result.data
142
+ if output is None:
143
+ console.print("[red]Research failed: No output data[/red]")
144
+ raise click.Abort()
145
+
146
+ # Generate markdown content
147
+ markdown_content = _generate_research_markdown(output)
148
+
149
+ # Create research file
150
+ frontmatter = {
151
+ "research_id": output.research_id,
152
+ "topic": output.topic,
153
+ "mitre_techniques": output.mitre_techniques,
154
+ "status": "completed",
155
+ "depth": depth,
156
+ "duration_minutes": round(output.total_duration_ms / 60000, 1),
157
+ "linked_hunts": [],
158
+ "web_searches": output.web_searches_performed,
159
+ "llm_calls": output.llm_calls,
160
+ "total_cost_usd": output.total_cost_usd,
161
+ "data_source_availability": output.data_source_availability,
162
+ "estimated_hunt_complexity": output.estimated_hunt_complexity,
163
+ }
164
+
165
+ file_path = manager.create_research_file(
166
+ research_id=output.research_id,
167
+ topic=output.topic,
168
+ content=markdown_content,
169
+ frontmatter=frontmatter,
170
+ )
171
+
172
+ # Display results
173
+ if output_format == "json":
174
+ _display_json_output(output)
175
+ else:
176
+ _display_research_summary(output, file_path)
177
+
178
+
179
+ @research.command(name="list")
180
+ @click.option("--status", help="Filter by status (draft, in_progress, completed)")
181
+ @click.option("--technique", help="Filter by MITRE technique")
182
+ @click.option("--output", "output_format", type=click.Choice(["table", "json"]), default="table")
183
+ def list_research(
184
+ status: Optional[str],
185
+ technique: Optional[str],
186
+ output_format: str,
187
+ ) -> None:
188
+ """List all research documents."""
189
+ from athf.core.research_manager import ResearchManager
190
+
191
+ manager = ResearchManager()
192
+ research_list = manager.list_research(status=status, technique=technique)
193
+
194
+ if not research_list:
195
+ console.print("[yellow]No research documents found[/yellow]")
196
+ return
197
+
198
+ if output_format == "json":
199
+ console.print(json.dumps(research_list, indent=2))
200
+ return
201
+
202
+ # Table output
203
+ table = Table(show_header=True, header_style="bold cyan")
204
+ table.add_column("ID", style="cyan", no_wrap=True)
205
+ table.add_column("Topic", style="white")
206
+ table.add_column("Status", style="yellow")
207
+ table.add_column("Techniques", style="dim")
208
+ table.add_column("Hunts", style="green")
209
+ table.add_column("Cost", style="dim")
210
+
211
+ for r in research_list:
212
+ techniques = ", ".join(r.get("mitre_techniques", [])[:2])
213
+ if len(r.get("mitre_techniques", [])) > 2:
214
+ techniques += "..."
215
+
216
+ linked_hunts = len(r.get("linked_hunts", []))
217
+ cost = f"${r.get('total_cost_usd', 0):.3f}"
218
+
219
+ table.add_row(
220
+ r.get("research_id", ""),
221
+ r.get("topic", "")[:40],
222
+ r.get("status", ""),
223
+ techniques,
224
+ str(linked_hunts) if linked_hunts > 0 else "-",
225
+ cost,
226
+ )
227
+
228
+ console.print(table)
229
+ console.print(f"\n[dim]Total: {len(research_list)} research documents[/dim]")
230
+
231
+
232
+ @research.command()
233
+ @click.argument("research_id")
234
+ @click.option("--output", "output_format", type=click.Choice(["markdown", "json"]), default="markdown")
235
+ def view(research_id: str, output_format: str) -> None:
236
+ """View a specific research document."""
237
+ from athf.core.research_manager import ResearchManager
238
+
239
+ manager = ResearchManager()
240
+ research_data = manager.get_research(research_id)
241
+
242
+ if not research_data:
243
+ console.print(f"[red]Research {research_id} not found[/red]")
244
+ raise click.Abort()
245
+
246
+ if output_format == "json":
247
+ console.print(json.dumps(research_data, indent=2, default=str))
248
+ return
249
+
250
+ # Display markdown content
251
+ file_path = research_data.get("file_path")
252
+ if file_path:
253
+ with open(file_path, "r") as f:
254
+ content = f.read()
255
+ console.print(content)
256
+ else:
257
+ console.print("[red]Research file not found[/red]")
258
+
259
+
260
+ @research.command()
261
+ @click.argument("query")
262
+ @click.option("--output", "output_format", type=click.Choice(["table", "json"]), default="table")
263
+ def search(query: str, output_format: str) -> None:
264
+ """Search across research documents."""
265
+ from athf.core.research_manager import ResearchManager
266
+
267
+ manager = ResearchManager()
268
+ results = manager.search_research(query)
269
+
270
+ if not results:
271
+ console.print(f"[yellow]No research documents found matching '{query}'[/yellow]")
272
+ return
273
+
274
+ if output_format == "json":
275
+ console.print(json.dumps(results, indent=2))
276
+ return
277
+
278
+ # Table output
279
+ table = Table(show_header=True, header_style="bold cyan")
280
+ table.add_column("ID", style="cyan", no_wrap=True)
281
+ table.add_column("Topic", style="white")
282
+ table.add_column("Status", style="yellow")
283
+
284
+ for r in results:
285
+ table.add_row(
286
+ r.get("research_id", ""),
287
+ r.get("topic", "")[:50],
288
+ r.get("status", ""),
289
+ )
290
+
291
+ console.print(f"\n[bold]Search results for '{query}':[/bold]")
292
+ console.print(table)
293
+
294
+
295
+ @research.command()
296
+ @click.option("--output", "output_format", type=click.Choice(["table", "json"]), default="table")
297
+ def stats(output_format: str) -> None:
298
+ """Show research program statistics."""
299
+ from athf.core.research_manager import ResearchManager
300
+
301
+ manager = ResearchManager()
302
+ statistics = manager.calculate_stats()
303
+
304
+ if output_format == "json":
305
+ console.print(json.dumps(statistics, indent=2))
306
+ return
307
+
308
+ # Display stats
309
+ console.print("\n[bold cyan]Research Program Statistics[/bold cyan]")
310
+ console.print(f"Total Research: {statistics['total_research']}")
311
+ console.print(f"Completed: {statistics['completed_research']}")
312
+ console.print(f"Total Cost: ${statistics['total_cost_usd']:.4f}")
313
+ console.print(f"Total Duration: {statistics['total_duration_minutes']} min")
314
+ console.print(f"Avg Duration: {statistics['avg_duration_minutes']:.1f} min")
315
+ console.print(f"Linked Hunts: {statistics['total_linked_hunts']}")
316
+
317
+ if statistics["by_status"]:
318
+ console.print("\n[bold]By Status:[/bold]")
319
+ for status, count in statistics["by_status"].items():
320
+ console.print(f" {status}: {count}")
321
+
322
+
323
+ def _generate_research_markdown(output: ResearchOutput) -> str: # noqa: C901
324
+ """Generate markdown content from research output."""
325
+ lines = []
326
+
327
+ # Title
328
+ lines.append(f"# {output.research_id}: {output.topic} Research")
329
+ lines.append("")
330
+ lines.append(f"**Topic:** {output.topic}")
331
+ if output.mitre_techniques:
332
+ lines.append(f"**MITRE ATT&CK:** {', '.join(output.mitre_techniques)}")
333
+ lines.append(f"**Duration:** {output.total_duration_ms / 60000:.1f} minutes")
334
+ lines.append("")
335
+ lines.append("---")
336
+ lines.append("")
337
+
338
+ # Skill 1: System Research
339
+ skill = output.system_research
340
+ lines.append("## 1. System Research: How It Works")
341
+ lines.append("")
342
+ lines.append("### Summary")
343
+ lines.append(skill.summary)
344
+ lines.append("")
345
+ lines.append("### Key Findings")
346
+ for finding in skill.key_findings:
347
+ lines.append(f"- {finding}")
348
+ lines.append("")
349
+ if skill.sources:
350
+ lines.append("### Sources")
351
+ for source in skill.sources:
352
+ lines.append(f"- [{source['title']}]({source['url']})")
353
+ lines.append("")
354
+ lines.append("---")
355
+ lines.append("")
356
+
357
+ # Skill 2: Adversary Tradecraft
358
+ skill = output.adversary_tradecraft
359
+ lines.append("## 2. Adversary Tradecraft: Attack Techniques")
360
+ lines.append("")
361
+ lines.append("### Summary")
362
+ lines.append(skill.summary)
363
+ lines.append("")
364
+ lines.append("### Key Findings")
365
+ for finding in skill.key_findings:
366
+ lines.append(f"- {finding}")
367
+ lines.append("")
368
+ if skill.sources:
369
+ lines.append("### Sources")
370
+ for source in skill.sources:
371
+ lines.append(f"- [{source['title']}]({source['url']})")
372
+ lines.append("")
373
+ lines.append("---")
374
+ lines.append("")
375
+
376
+ # Skill 3: Telemetry Mapping
377
+ skill = output.telemetry_mapping
378
+ lines.append("## 3. Telemetry Mapping: OCSF Fields")
379
+ lines.append("")
380
+ lines.append("### Summary")
381
+ lines.append(skill.summary)
382
+ lines.append("")
383
+ lines.append("### Key Fields")
384
+ for finding in skill.key_findings:
385
+ lines.append(f"- {finding}")
386
+ lines.append("")
387
+ lines.append("### Data Source Availability")
388
+ for data_source, available in output.data_source_availability.items():
389
+ status = "Available" if available else "Limited/Unavailable"
390
+ lines.append(f"- {data_source.replace('_', ' ').title()}: {status}")
391
+ lines.append("")
392
+ lines.append("---")
393
+ lines.append("")
394
+
395
+ # Skill 4: Related Work
396
+ skill = output.related_work
397
+ lines.append("## 4. Related Work: Past Hunts")
398
+ lines.append("")
399
+ lines.append("### Summary")
400
+ lines.append(skill.summary)
401
+ lines.append("")
402
+ if skill.key_findings:
403
+ lines.append("### Related Hunts")
404
+ for finding in skill.key_findings:
405
+ lines.append(f"- {finding}")
406
+ lines.append("")
407
+ lines.append("---")
408
+ lines.append("")
409
+
410
+ # Skill 5: Synthesis
411
+ skill = output.synthesis
412
+ lines.append("## 5. Research Synthesis")
413
+ lines.append("")
414
+ lines.append("### Executive Summary")
415
+ lines.append(skill.summary)
416
+ lines.append("")
417
+ if output.recommended_hypothesis:
418
+ lines.append("### Recommended Hypothesis")
419
+ lines.append(f"> {output.recommended_hypothesis}")
420
+ lines.append("")
421
+ if output.gaps_identified:
422
+ lines.append("### Gaps Identified")
423
+ for gap in output.gaps_identified:
424
+ lines.append(f"- {gap}")
425
+ lines.append("")
426
+ lines.append("### Key Findings")
427
+ for finding in skill.key_findings:
428
+ lines.append(f"- {finding}")
429
+ lines.append("")
430
+ lines.append("### Hunt Complexity Assessment")
431
+ lines.append(f"**Estimated Complexity:** {output.estimated_hunt_complexity.title()}")
432
+ lines.append("")
433
+
434
+ # Appendix: Cost Tracking
435
+ lines.append("---")
436
+ lines.append("")
437
+ lines.append("## Appendix: Research Metrics")
438
+ lines.append("")
439
+ lines.append(f"- Web Searches: {output.web_searches_performed}")
440
+ lines.append(f"- LLM Calls: {output.llm_calls}")
441
+ lines.append(f"- Total Cost: ${output.total_cost_usd:.4f}")
442
+ lines.append(f"- Duration: {output.total_duration_ms / 1000:.1f} seconds")
443
+ lines.append("")
444
+
445
+ return "\n".join(lines)
446
+
447
+
448
+ def _display_research_summary(output: ResearchOutput, file_path: Path) -> None:
449
+ """Display research summary in rich format."""
450
+ # Success panel
451
+ console.print()
452
+ console.print(
453
+ Panel(
454
+ f"[bold green]Research Complete: {output.research_id}[/bold green]\n\n"
455
+ f"[bold]Topic:[/bold] {output.topic}\n"
456
+ f"[bold]Duration:[/bold] {output.total_duration_ms / 1000:.1f} seconds\n"
457
+ f"[bold]Cost:[/bold] ${output.total_cost_usd:.4f}\n"
458
+ f"[bold]File:[/bold] {file_path}",
459
+ title="Research Complete",
460
+ border_style="green",
461
+ )
462
+ )
463
+
464
+ # Summary of findings
465
+ console.print("\n[bold cyan]Key Findings Summary[/bold cyan]")
466
+
467
+ # System Research
468
+ console.print(f"\n[bold]1. System Research:[/bold] {output.system_research.summary[:100]}...")
469
+
470
+ # Adversary Tradecraft
471
+ console.print(f"\n[bold]2. Adversary Tradecraft:[/bold] {output.adversary_tradecraft.summary[:100]}...")
472
+
473
+ # Recommended Hypothesis
474
+ if output.recommended_hypothesis:
475
+ console.print("\n[bold green]Recommended Hypothesis:[/bold green]")
476
+ console.print(f" {output.recommended_hypothesis}")
477
+
478
+ # Gaps
479
+ if output.gaps_identified:
480
+ console.print("\n[bold yellow]Gaps Identified:[/bold yellow]")
481
+ for gap in output.gaps_identified[:3]:
482
+ console.print(f" - {gap}")
483
+
484
+ # Next steps
485
+ console.print("\n[bold]Next Steps:[/bold]")
486
+ console.print(f" 1. Review full research: athf research view {output.research_id}")
487
+ console.print(" 2. Generate hypothesis: athf agent run hypothesis-generator")
488
+ console.print(f" 3. Create hunt: athf hunt new --research {output.research_id}")
489
+
490
+
491
+ def _display_json_output(output: ResearchOutput) -> None:
492
+ """Display research output as JSON."""
493
+ data = {
494
+ "research_id": output.research_id,
495
+ "topic": output.topic,
496
+ "mitre_techniques": output.mitre_techniques,
497
+ "system_research": {
498
+ "summary": output.system_research.summary,
499
+ "key_findings": output.system_research.key_findings,
500
+ "sources": output.system_research.sources,
501
+ },
502
+ "adversary_tradecraft": {
503
+ "summary": output.adversary_tradecraft.summary,
504
+ "key_findings": output.adversary_tradecraft.key_findings,
505
+ "sources": output.adversary_tradecraft.sources,
506
+ },
507
+ "telemetry_mapping": {
508
+ "summary": output.telemetry_mapping.summary,
509
+ "key_findings": output.telemetry_mapping.key_findings,
510
+ },
511
+ "related_work": {
512
+ "summary": output.related_work.summary,
513
+ "key_findings": output.related_work.key_findings,
514
+ },
515
+ "synthesis": {
516
+ "summary": output.synthesis.summary,
517
+ "key_findings": output.synthesis.key_findings,
518
+ },
519
+ "recommended_hypothesis": output.recommended_hypothesis,
520
+ "data_source_availability": output.data_source_availability,
521
+ "estimated_hunt_complexity": output.estimated_hunt_complexity,
522
+ "gaps_identified": output.gaps_identified,
523
+ "metrics": {
524
+ "duration_ms": output.total_duration_ms,
525
+ "web_searches": output.web_searches_performed,
526
+ "llm_calls": output.llm_calls,
527
+ "cost_usd": output.total_cost_usd,
528
+ },
529
+ }
530
+ console.print(json.dumps(data, indent=2))
athf/commands/similar.py CHANGED
@@ -121,7 +121,7 @@ def _get_hunt_text(hunt_id: str) -> Optional[str]:
121
121
  hunt_file = Path(f"hunts/{hunt_id}.md")
122
122
  if not hunt_file.exists():
123
123
  return None
124
- return hunt_file.read_text(encoding='utf-8')
124
+ return hunt_file.read_text(encoding="utf-8")
125
125
 
126
126
 
127
127
  def _find_similar_hunts(
@@ -144,7 +144,7 @@ def _find_similar_hunts(
144
144
  hunt_files = list(hunts_dir.glob("H-*.md"))
145
145
 
146
146
  if not hunt_files:
147
- console.print("[yellow]No hunts found in hunts/ directory[/yellow]")
147
+ # Don't print warning - let the output format handle empty results
148
148
  return []
149
149
 
150
150
  # Extract hunt content and metadata
@@ -156,7 +156,7 @@ def _find_similar_hunts(
156
156
  if exclude_hunt and hunt_id == exclude_hunt:
157
157
  continue
158
158
 
159
- content = hunt_file.read_text(encoding='utf-8')
159
+ content = hunt_file.read_text(encoding="utf-8")
160
160
  metadata = _extract_hunt_metadata(content)
161
161
 
162
162
  # Extract searchable text (weighted semantic sections)
@@ -172,7 +172,7 @@ def _find_similar_hunts(
172
172
  )
173
173
 
174
174
  if not hunt_data:
175
- console.print("[yellow]No hunts available for comparison[/yellow]")
175
+ # Don't print warning - let the output format handle empty results
176
176
  return []
177
177
 
178
178
  # Build TF-IDF vectors using searchable text (weighted semantic sections)
@@ -233,7 +233,7 @@ def _extract_hunt_metadata(content: str) -> Dict[str, Any]:
233
233
  return {}
234
234
 
235
235
 
236
- def _extract_searchable_text(content: str, metadata: Dict[str, Any]) -> str:
236
+ def _extract_searchable_text(content: str, metadata: Dict[str, Any]) -> str: # noqa: C901
237
237
  """Extract semantically important text for similarity matching.
238
238
 
239
239
  Focuses on key sections and applies weighting to improve match accuracy: