aihound 3.2.1__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.
- aihound/__init__.py +3 -0
- aihound/__main__.py +7 -0
- aihound/cli.py +456 -0
- aihound/core/__init__.py +0 -0
- aihound/core/mcp.py +297 -0
- aihound/core/permissions.py +185 -0
- aihound/core/platform.py +203 -0
- aihound/core/redactor.py +112 -0
- aihound/core/scanner.py +131 -0
- aihound/mcp_server.py +312 -0
- aihound/notifications.py +184 -0
- aihound/output/__init__.py +0 -0
- aihound/output/html_report.py +336 -0
- aihound/output/json_export.py +60 -0
- aihound/output/opengraph_export.py +664 -0
- aihound/output/table.py +208 -0
- aihound/output/watch_formatters.py +199 -0
- aihound/remediation.py +105 -0
- aihound/scanners/__init__.py +30 -0
- aihound/scanners/aider.py +138 -0
- aihound/scanners/amazon_q.py +147 -0
- aihound/scanners/browser_sessions.py +519 -0
- aihound/scanners/chatgpt.py +118 -0
- aihound/scanners/claude_code.py +333 -0
- aihound/scanners/claude_desktop.py +60 -0
- aihound/scanners/claude_sessions.py +564 -0
- aihound/scanners/cline.py +64 -0
- aihound/scanners/continue_dev.py +141 -0
- aihound/scanners/cursor.py +62 -0
- aihound/scanners/docker.py +319 -0
- aihound/scanners/envvars.py +139 -0
- aihound/scanners/gemini.py +190 -0
- aihound/scanners/git_credentials.py +238 -0
- aihound/scanners/github_copilot.py +196 -0
- aihound/scanners/huggingface.py +97 -0
- aihound/scanners/jupyter.py +341 -0
- aihound/scanners/lm_studio.py +274 -0
- aihound/scanners/ml_platforms.py +209 -0
- aihound/scanners/network_exposure.py +132 -0
- aihound/scanners/ollama.py +299 -0
- aihound/scanners/openai_cli.py +234 -0
- aihound/scanners/openclaw.py +419 -0
- aihound/scanners/persistent_env.py +453 -0
- aihound/scanners/powershell.py +287 -0
- aihound/scanners/shell_history.py +286 -0
- aihound/scanners/shell_rc.py +316 -0
- aihound/scanners/vscode_extensions.py +231 -0
- aihound/scanners/windsurf.py +129 -0
- aihound/utils/__init__.py +0 -0
- aihound/utils/credman.py +65 -0
- aihound/utils/keychain.py +90 -0
- aihound/utils/vscdb.py +80 -0
- aihound/watch.py +308 -0
- aihound-3.2.1.dist-info/LICENSE +21 -0
- aihound-3.2.1.dist-info/METADATA +271 -0
- aihound-3.2.1.dist-info/RECORD +59 -0
- aihound-3.2.1.dist-info/WHEEL +5 -0
- aihound-3.2.1.dist-info/entry_points.txt +2 -0
- aihound-3.2.1.dist-info/top_level.txt +1 -0
aihound/__init__.py
ADDED
aihound/__main__.py
ADDED
aihound/cli.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""CLI entry point for AIHound."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from aihound import __version__
|
|
11
|
+
from aihound.core.platform import detect_platform
|
|
12
|
+
from aihound.scanners import get_all_scanners
|
|
13
|
+
from aihound.output.table import print_banner, print_results
|
|
14
|
+
from aihound.output.json_export import export_json
|
|
15
|
+
from aihound.output.html_report import export_html
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("aihound")
|
|
18
|
+
|
|
19
|
+
# Resolve the banner image path relative to the project root
|
|
20
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
21
|
+
_PROJECT_ROOT = _PACKAGE_DIR.parent
|
|
22
|
+
_DEFAULT_BANNER = _PROJECT_ROOT / "aihound.png"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _setup_logging(verbose: bool = False, json_output: bool = False) -> None:
|
|
26
|
+
"""Configure logging levels and format.
|
|
27
|
+
|
|
28
|
+
- Default: WARNING and above (quiet — only problems)
|
|
29
|
+
- --verbose: DEBUG and above (scanner progress, paths checked, stack traces on errors)
|
|
30
|
+
- --json: logs go to stderr so stdout stays clean for JSON
|
|
31
|
+
"""
|
|
32
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
33
|
+
stream = sys.stderr if json_output else sys.stdout
|
|
34
|
+
handler = logging.StreamHandler(stream)
|
|
35
|
+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
36
|
+
|
|
37
|
+
root = logging.getLogger("aihound")
|
|
38
|
+
root.setLevel(level)
|
|
39
|
+
root.handlers = [handler]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="aihound",
|
|
45
|
+
description="AIHound - AI Credential & Secrets Scanner",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--version", action="version", version=f"aihound {__version__}"
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--show-secrets",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Show actual credential values (USE WITH CAUTION)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--json",
|
|
57
|
+
action="store_true",
|
|
58
|
+
dest="json_output",
|
|
59
|
+
help="Output results as JSON to stdout",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--json-file",
|
|
63
|
+
type=str,
|
|
64
|
+
metavar="PATH",
|
|
65
|
+
help="Write JSON report to file",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--html-file",
|
|
69
|
+
type=str,
|
|
70
|
+
metavar="PATH",
|
|
71
|
+
help="Write HTML report to file",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--banner",
|
|
75
|
+
type=str,
|
|
76
|
+
metavar="PATH",
|
|
77
|
+
help="Custom banner image for HTML report (default: aihound.png)",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--bloodhound",
|
|
81
|
+
type=str,
|
|
82
|
+
metavar="PATH",
|
|
83
|
+
help="Write BloodHound CE OpenGraph JSON to file (for attack path visualization)",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--tools",
|
|
87
|
+
nargs="+",
|
|
88
|
+
metavar="TOOL",
|
|
89
|
+
help="Only scan specific tools (use slugs from --list-tools)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--list-tools",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="List all available scanners and exit",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--verbose", "-v",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Show debug output (paths checked, errors, stack traces)",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--no-color",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="Disable colored output",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Watch/monitor mode flags
|
|
108
|
+
watch_group = parser.add_argument_group(
|
|
109
|
+
"watch mode", "Continuous monitoring: re-scan on an interval and emit events on changes"
|
|
110
|
+
)
|
|
111
|
+
watch_group.add_argument(
|
|
112
|
+
"--watch",
|
|
113
|
+
action="store_true",
|
|
114
|
+
help="Run continuously, alerting on new/changed credentials (Ctrl+C to stop)",
|
|
115
|
+
)
|
|
116
|
+
watch_group.add_argument(
|
|
117
|
+
"--interval",
|
|
118
|
+
type=float,
|
|
119
|
+
default=30.0,
|
|
120
|
+
metavar="SECONDS",
|
|
121
|
+
help="Polling interval in seconds (default: 30)",
|
|
122
|
+
)
|
|
123
|
+
watch_group.add_argument(
|
|
124
|
+
"--watch-log",
|
|
125
|
+
type=str,
|
|
126
|
+
metavar="PATH",
|
|
127
|
+
help="Append watch events as NDJSON to this file",
|
|
128
|
+
)
|
|
129
|
+
watch_group.add_argument(
|
|
130
|
+
"--notify",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="Fire OS-native desktop notifications for watch events",
|
|
133
|
+
)
|
|
134
|
+
watch_group.add_argument(
|
|
135
|
+
"--notify-min-risk",
|
|
136
|
+
type=str,
|
|
137
|
+
choices=["critical", "high", "medium", "low", "info"],
|
|
138
|
+
default="high",
|
|
139
|
+
metavar="LEVEL",
|
|
140
|
+
help="Minimum risk level to notify on (default: high)",
|
|
141
|
+
)
|
|
142
|
+
watch_group.add_argument(
|
|
143
|
+
"--min-risk",
|
|
144
|
+
type=str,
|
|
145
|
+
choices=["critical", "high", "medium", "low", "info"],
|
|
146
|
+
default="info",
|
|
147
|
+
metavar="LEVEL",
|
|
148
|
+
help="Minimum risk level for watch events (default: info — show all)",
|
|
149
|
+
)
|
|
150
|
+
watch_group.add_argument(
|
|
151
|
+
"--debounce",
|
|
152
|
+
type=float,
|
|
153
|
+
default=10.0,
|
|
154
|
+
metavar="SECONDS",
|
|
155
|
+
help="Suppress duplicate events within this window (default: 10, 0 disables)",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# MCP server mode
|
|
159
|
+
mcp_group = parser.add_argument_group(
|
|
160
|
+
"mcp server mode", "Expose AIHound to AI assistants via Model Context Protocol"
|
|
161
|
+
)
|
|
162
|
+
mcp_group.add_argument(
|
|
163
|
+
"--mcp",
|
|
164
|
+
action="store_true",
|
|
165
|
+
help=(
|
|
166
|
+
"Run as an MCP stdio server. Requires `pip install aihound[mcp]`. "
|
|
167
|
+
"Use in an MCP client config (e.g. claude_desktop_config.json) rather "
|
|
168
|
+
"than invoking directly."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return parser
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: list[str] | None = None) -> int:
|
|
176
|
+
parser = build_parser()
|
|
177
|
+
args = parser.parse_args(argv)
|
|
178
|
+
|
|
179
|
+
# MCP server mode takes over immediately — no banner, no scan, no output formatting.
|
|
180
|
+
# Logs go to stderr so stdout stays clean for JSON-RPC.
|
|
181
|
+
if args.mcp:
|
|
182
|
+
return _run_mcp_mode(verbose=args.verbose)
|
|
183
|
+
|
|
184
|
+
_setup_logging(verbose=args.verbose, json_output=args.json_output)
|
|
185
|
+
|
|
186
|
+
scanners = get_all_scanners()
|
|
187
|
+
|
|
188
|
+
# --list-tools
|
|
189
|
+
if args.list_tools:
|
|
190
|
+
print("Available scanners:")
|
|
191
|
+
for s in scanners:
|
|
192
|
+
applicable = "yes" if s.is_applicable() else "no (not applicable on this platform)"
|
|
193
|
+
print(f" {s.slug():<20} {s.name():<30} Applicable: {applicable}")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
# --show-secrets safety gate
|
|
197
|
+
show_secrets = False
|
|
198
|
+
if args.show_secrets:
|
|
199
|
+
if not sys.stdin.isatty():
|
|
200
|
+
print(
|
|
201
|
+
"ERROR: --show-secrets requires an interactive terminal. "
|
|
202
|
+
"Refusing to expose credentials in non-interactive mode.",
|
|
203
|
+
file=sys.stderr,
|
|
204
|
+
)
|
|
205
|
+
return 1
|
|
206
|
+
logger.warning(
|
|
207
|
+
"--show-secrets will display raw credential values. "
|
|
208
|
+
"Only use on YOUR OWN machine for research purposes."
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
confirm = input("Type 'YES' to confirm: ")
|
|
212
|
+
if confirm.strip() != "YES":
|
|
213
|
+
print("Aborted.")
|
|
214
|
+
return 1
|
|
215
|
+
except (EOFError, KeyboardInterrupt):
|
|
216
|
+
print("\nAborted.")
|
|
217
|
+
return 1
|
|
218
|
+
show_secrets = True
|
|
219
|
+
|
|
220
|
+
# Filter scanners
|
|
221
|
+
if args.tools:
|
|
222
|
+
tool_slugs = set(args.tools)
|
|
223
|
+
scanners = [s for s in scanners if s.slug() in tool_slugs]
|
|
224
|
+
if not scanners:
|
|
225
|
+
logger.error("No scanners matched: %s — use --list-tools to see available scanners.", args.tools)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
# Filter by platform applicability
|
|
229
|
+
scanners = [s for s in scanners if s.is_applicable()]
|
|
230
|
+
|
|
231
|
+
# Watch/monitor mode: takes over and never reaches the one-shot output path below
|
|
232
|
+
if args.watch:
|
|
233
|
+
return _run_watch_mode(scanners, args, show_secrets)
|
|
234
|
+
|
|
235
|
+
# Print banner (unless JSON-only output)
|
|
236
|
+
if not args.json_output:
|
|
237
|
+
plat = detect_platform()
|
|
238
|
+
print_banner(no_color=args.no_color)
|
|
239
|
+
print(f"Platform: {plat.value}")
|
|
240
|
+
if plat.value == "wsl":
|
|
241
|
+
print("WSL detected - scanning both Linux and Windows credential paths\n")
|
|
242
|
+
else:
|
|
243
|
+
print()
|
|
244
|
+
|
|
245
|
+
# Run scanners
|
|
246
|
+
results = []
|
|
247
|
+
for scanner in scanners:
|
|
248
|
+
logger.debug("Scanning: %s...", scanner.name())
|
|
249
|
+
result = scanner.run(show_secrets=show_secrets)
|
|
250
|
+
results.append(result)
|
|
251
|
+
|
|
252
|
+
for err in result.errors:
|
|
253
|
+
logger.warning("[%s] %s", scanner.name(), err)
|
|
254
|
+
|
|
255
|
+
# Output
|
|
256
|
+
if args.json_output:
|
|
257
|
+
export_json(results, file=sys.stdout)
|
|
258
|
+
else:
|
|
259
|
+
print_results(results, no_color=args.no_color, verbose=args.verbose)
|
|
260
|
+
|
|
261
|
+
if args.json_file:
|
|
262
|
+
out_path = _prepare_output_path(args.json_file, "JSON")
|
|
263
|
+
if out_path is None:
|
|
264
|
+
return 1
|
|
265
|
+
try:
|
|
266
|
+
export_json(results, filepath=str(out_path))
|
|
267
|
+
except OSError as e:
|
|
268
|
+
print(f"ERROR: Failed to write JSON report to {out_path}: {e}", file=sys.stderr)
|
|
269
|
+
return 1
|
|
270
|
+
logger.info("JSON report written to: %s", out_path)
|
|
271
|
+
if not args.json_output:
|
|
272
|
+
print(f"\nJSON report written to: {out_path}")
|
|
273
|
+
|
|
274
|
+
if args.html_file:
|
|
275
|
+
out_path = _prepare_output_path(args.html_file, "HTML")
|
|
276
|
+
if out_path is None:
|
|
277
|
+
return 1
|
|
278
|
+
banner = Path(args.banner).expanduser() if args.banner else _DEFAULT_BANNER
|
|
279
|
+
try:
|
|
280
|
+
export_html(results, filepath=str(out_path), banner_path=banner)
|
|
281
|
+
except OSError as e:
|
|
282
|
+
print(f"ERROR: Failed to write HTML report to {out_path}: {e}", file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
logger.info("HTML report written to: %s", out_path)
|
|
285
|
+
if not args.json_output:
|
|
286
|
+
print(f"HTML report written to: {out_path}")
|
|
287
|
+
|
|
288
|
+
if args.bloodhound:
|
|
289
|
+
out_path = _prepare_output_path(args.bloodhound, "BloodHound")
|
|
290
|
+
if out_path is None:
|
|
291
|
+
return 1
|
|
292
|
+
try:
|
|
293
|
+
from aihound.output.opengraph_export import export_opengraph
|
|
294
|
+
export_opengraph(results, filepath=str(out_path))
|
|
295
|
+
except ImportError:
|
|
296
|
+
print(
|
|
297
|
+
"ERROR: aihound400 package not found. Ensure the aihound400 directory "
|
|
298
|
+
"is in your Python path.",
|
|
299
|
+
file=sys.stderr,
|
|
300
|
+
)
|
|
301
|
+
return 1
|
|
302
|
+
except OSError as e:
|
|
303
|
+
print(f"ERROR: Failed to write BloodHound export to {out_path}: {e}", file=sys.stderr)
|
|
304
|
+
return 1
|
|
305
|
+
logger.info("BloodHound OpenGraph JSON written to: %s", out_path)
|
|
306
|
+
if not args.json_output:
|
|
307
|
+
print(f"BloodHound OpenGraph JSON written to: {out_path}")
|
|
308
|
+
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _prepare_output_path(raw_path: str, label: str) -> Path | None:
|
|
313
|
+
"""Normalize an output file path: expand ~, resolve, auto-create parent dir.
|
|
314
|
+
|
|
315
|
+
Returns the resolved Path on success, None on failure (after printing an error).
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
path = Path(raw_path).expanduser()
|
|
319
|
+
except (RuntimeError, ValueError) as e:
|
|
320
|
+
print(f"ERROR: Invalid {label} output path '{raw_path}': {e}", file=sys.stderr)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
parent = path.parent
|
|
324
|
+
if parent and not parent.exists():
|
|
325
|
+
try:
|
|
326
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
logger.debug("Created parent directory: %s", parent)
|
|
328
|
+
except OSError as e:
|
|
329
|
+
print(
|
|
330
|
+
f"ERROR: Cannot create directory {parent} for {label} output: {e}\n"
|
|
331
|
+
f" Check that you have write permission to this location.",
|
|
332
|
+
file=sys.stderr,
|
|
333
|
+
)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
# Preflight write permission check
|
|
337
|
+
if path.exists():
|
|
338
|
+
if not path.is_file():
|
|
339
|
+
print(
|
|
340
|
+
f"ERROR: {label} output path {path} exists but is not a regular file.",
|
|
341
|
+
file=sys.stderr,
|
|
342
|
+
)
|
|
343
|
+
return None
|
|
344
|
+
return path
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _run_watch_mode(scanners: list, args: argparse.Namespace, show_secrets: bool) -> int:
|
|
348
|
+
"""Entry point for --watch. Builds sinks, starts the watch loop."""
|
|
349
|
+
# Import here to keep watch-mode deps lazy
|
|
350
|
+
from aihound.core.scanner import RiskLevel
|
|
351
|
+
from aihound.watch import WatchLoop
|
|
352
|
+
from aihound.output.watch_formatters import (
|
|
353
|
+
NDJSONEventSink,
|
|
354
|
+
NotificationEventSink,
|
|
355
|
+
TerminalEventSink,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
risk_map = {
|
|
359
|
+
"critical": RiskLevel.CRITICAL,
|
|
360
|
+
"high": RiskLevel.HIGH,
|
|
361
|
+
"medium": RiskLevel.MEDIUM,
|
|
362
|
+
"low": RiskLevel.LOW,
|
|
363
|
+
"info": RiskLevel.INFO,
|
|
364
|
+
}
|
|
365
|
+
min_risk = risk_map[args.min_risk]
|
|
366
|
+
notify_min_risk = risk_map[args.notify_min_risk]
|
|
367
|
+
|
|
368
|
+
# Build sinks
|
|
369
|
+
sinks = []
|
|
370
|
+
|
|
371
|
+
if args.json_output:
|
|
372
|
+
# In JSON mode, NDJSON to stdout is the primary output
|
|
373
|
+
sinks.append(NDJSONEventSink(file=sys.stdout))
|
|
374
|
+
else:
|
|
375
|
+
# Otherwise terminal is primary — banner + live events
|
|
376
|
+
plat = detect_platform()
|
|
377
|
+
print_banner(no_color=args.no_color)
|
|
378
|
+
print(f"Platform: {plat.value}")
|
|
379
|
+
if plat.value == "wsl":
|
|
380
|
+
print("WSL detected - scanning both Linux and Windows credential paths")
|
|
381
|
+
print(
|
|
382
|
+
f"Watch mode: interval={int(args.interval)}s, scanners={len(scanners)}, "
|
|
383
|
+
f"min-risk={args.min_risk}. Press Ctrl+C to stop.\n"
|
|
384
|
+
)
|
|
385
|
+
sinks.append(TerminalEventSink(no_color=args.no_color))
|
|
386
|
+
|
|
387
|
+
if args.watch_log:
|
|
388
|
+
try:
|
|
389
|
+
sinks.append(NDJSONEventSink(filepath=args.watch_log))
|
|
390
|
+
except OSError as e:
|
|
391
|
+
print(f"ERROR: Cannot open watch log {args.watch_log}: {e}", file=sys.stderr)
|
|
392
|
+
return 1
|
|
393
|
+
|
|
394
|
+
if args.notify:
|
|
395
|
+
sinks.append(NotificationEventSink(min_risk=notify_min_risk))
|
|
396
|
+
|
|
397
|
+
loop = WatchLoop(
|
|
398
|
+
scanners=scanners,
|
|
399
|
+
sinks=sinks,
|
|
400
|
+
interval=args.interval,
|
|
401
|
+
min_risk=min_risk,
|
|
402
|
+
debounce_seconds=args.debounce,
|
|
403
|
+
show_secrets=show_secrets,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
event_count = loop.run()
|
|
408
|
+
except KeyboardInterrupt:
|
|
409
|
+
event_count = 0
|
|
410
|
+
|
|
411
|
+
# Close any owned file handles in sinks
|
|
412
|
+
for sink in sinks:
|
|
413
|
+
close = getattr(sink, "close", None)
|
|
414
|
+
if callable(close):
|
|
415
|
+
close()
|
|
416
|
+
|
|
417
|
+
# Final summary to stderr so it doesn't pollute NDJSON stdout
|
|
418
|
+
if not args.json_output:
|
|
419
|
+
print(f"\nWatch stopped. {event_count} event(s) emitted.", file=sys.stderr)
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _run_mcp_mode(verbose: bool = False) -> int:
|
|
424
|
+
"""Entry point for --mcp. Runs AIHound as an MCP stdio server.
|
|
425
|
+
|
|
426
|
+
Prints a friendly install hint (to stderr) and exits 1 if the optional
|
|
427
|
+
`mcp` SDK isn't installed.
|
|
428
|
+
"""
|
|
429
|
+
# All logging to stderr — stdout is reserved for JSON-RPC
|
|
430
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
431
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
432
|
+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
433
|
+
root = logging.getLogger("aihound")
|
|
434
|
+
root.setLevel(level)
|
|
435
|
+
root.handlers = [handler]
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
from aihound.mcp_server import run_server
|
|
439
|
+
except ImportError as e:
|
|
440
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
441
|
+
return 1
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
run_server()
|
|
445
|
+
except ImportError as e:
|
|
446
|
+
# run_server() raises ImportError with a helpful message when the mcp SDK is missing
|
|
447
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
448
|
+
return 1
|
|
449
|
+
except KeyboardInterrupt:
|
|
450
|
+
logger.debug("MCP server interrupted")
|
|
451
|
+
|
|
452
|
+
return 0
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
if __name__ == "__main__":
|
|
456
|
+
sys.exit(main())
|
aihound/core/__init__.py
ADDED
|
File without changes
|