mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-02-12 22:32:45 +00:00
feat: FuzzForge AI - complete rewrite for OSS release
This commit is contained in:
37
fuzzforge-cli/Makefile
Normal file
37
fuzzforge-cli/Makefile
Normal file
@@ -0,0 +1,37 @@
|
||||
PACKAGE=$(word 1, $(shell uv version))
|
||||
VERSION=$(word 2, $(shell uv version))
|
||||
|
||||
SOURCES=./src
|
||||
TESTS=./tests
|
||||
|
||||
.PHONY: bandit clean cloc format mypy pytest ruff version
|
||||
|
||||
bandit:
|
||||
uv run bandit --recursive $(SOURCES)
|
||||
|
||||
clean:
|
||||
@find . -type d \( \
|
||||
-name '*.egg-info' \
|
||||
-o -name '.mypy_cache' \
|
||||
-o -name '.pytest_cache' \
|
||||
-o -name '.ruff_cache' \
|
||||
-o -name '__pycache__' \
|
||||
\) -printf 'removing directory %p\n' -exec rm -rf {} +
|
||||
|
||||
cloc:
|
||||
cloc $(SOURCES) $(TESTS)
|
||||
|
||||
format:
|
||||
uv run ruff format $(SOURCES) $(TESTS)
|
||||
|
||||
mypy:
|
||||
uv run mypy $(SOURCES) $(TESTS)
|
||||
|
||||
pytest:
|
||||
uv run pytest -vv $(TESTS)
|
||||
|
||||
ruff:
|
||||
uv run ruff check --fix $(SOURCES) $(TESTS)
|
||||
|
||||
version:
|
||||
@echo '$(PACKAGE)@$(VERSION)'
|
||||
3
fuzzforge-cli/README.md
Normal file
3
fuzzforge-cli/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# FuzzForge CLI
|
||||
|
||||
...
|
||||
6
fuzzforge-cli/mypy.ini
Normal file
6
fuzzforge-cli/mypy.ini
Normal file
@@ -0,0 +1,6 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
strict = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
warn_return_any = True
|
||||
30
fuzzforge-cli/pyproject.toml
Normal file
30
fuzzforge-cli/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "fuzzforge-cli"
|
||||
version = "0.0.1"
|
||||
description = "FuzzForge CLI - Command-line interface for FuzzForge OSS."
|
||||
authors = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fuzzforge-runner==0.0.1",
|
||||
"fuzzforge-types==0.0.1",
|
||||
"rich>=14.0.0",
|
||||
"typer==0.20.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
lints = [
|
||||
"bandit==1.8.6",
|
||||
"mypy==1.18.2",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
tests = [
|
||||
"pytest==9.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fuzzforge = "fuzzforge_cli.__main__:main"
|
||||
|
||||
[tool.uv.sources]
|
||||
fuzzforge-runner = { workspace = true }
|
||||
fuzzforge-types = { workspace = true }
|
||||
2
fuzzforge-cli/pytest.ini
Normal file
2
fuzzforge-cli/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
15
fuzzforge-cli/ruff.toml
Normal file
15
fuzzforge-cli/ruff.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [ "ALL" ]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D203", # conflicts with 'D211'
|
||||
"D213", # conflicts with 'D212'
|
||||
]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"PLR2004", # allowing comparisons using unamed numerical constants in tests
|
||||
"S101", # allowing 'assert' statements in tests
|
||||
]
|
||||
1
fuzzforge-cli/src/fuzzforge_cli/__init__.py
Normal file
1
fuzzforge-cli/src/fuzzforge_cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
12
fuzzforge-cli/src/fuzzforge_cli/__main__.py
Normal file
12
fuzzforge-cli/src/fuzzforge_cli/__main__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""TODO."""
|
||||
|
||||
from fuzzforge_cli.application import application
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO."""
|
||||
application()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
96
fuzzforge-cli/src/fuzzforge_cli/application.py
Normal file
96
fuzzforge-cli/src/fuzzforge_cli/application.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""FuzzForge CLI application."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fuzzforge_runner import Runner, Settings
|
||||
from typer import Context as TyperContext
|
||||
from typer import Option, Typer
|
||||
|
||||
from fuzzforge_cli.commands import mcp, modules, projects
|
||||
from fuzzforge_cli.context import Context
|
||||
|
||||
application: Typer = Typer(
|
||||
name="fuzzforge",
|
||||
help="FuzzForge OSS - Security research orchestration platform.",
|
||||
)
|
||||
|
||||
|
||||
@application.callback()
|
||||
def main(
|
||||
project_path: Annotated[
|
||||
Path,
|
||||
Option(
|
||||
"--project",
|
||||
"-p",
|
||||
envvar="FUZZFORGE_PROJECT__DEFAULT_PATH",
|
||||
help="Path to the FuzzForge project directory.",
|
||||
),
|
||||
] = Path.cwd(),
|
||||
modules_path: Annotated[
|
||||
Path,
|
||||
Option(
|
||||
"--modules",
|
||||
"-m",
|
||||
envvar="FUZZFORGE_MODULES_PATH",
|
||||
help="Path to the modules directory.",
|
||||
),
|
||||
] = Path.home() / ".fuzzforge" / "modules",
|
||||
storage_path: Annotated[
|
||||
Path,
|
||||
Option(
|
||||
"--storage",
|
||||
envvar="FUZZFORGE_STORAGE__PATH",
|
||||
help="Path to the storage directory.",
|
||||
),
|
||||
] = Path.home() / ".fuzzforge" / "storage",
|
||||
engine_type: Annotated[
|
||||
str,
|
||||
Option(
|
||||
"--engine",
|
||||
envvar="FUZZFORGE_ENGINE__TYPE",
|
||||
help="Container engine type (docker or podman).",
|
||||
),
|
||||
] = "podman",
|
||||
engine_socket: Annotated[
|
||||
str,
|
||||
Option(
|
||||
"--socket",
|
||||
envvar="FUZZFORGE_ENGINE__SOCKET",
|
||||
help="Container engine socket path.",
|
||||
),
|
||||
] = "",
|
||||
context: TyperContext = None, # type: ignore[assignment]
|
||||
) -> None:
|
||||
"""FuzzForge OSS - Security research orchestration platform.
|
||||
|
||||
Execute security research modules in isolated containers.
|
||||
|
||||
"""
|
||||
from fuzzforge_runner.settings import EngineSettings, ProjectSettings, StorageSettings
|
||||
|
||||
settings = Settings(
|
||||
engine=EngineSettings(
|
||||
type=engine_type, # type: ignore[arg-type]
|
||||
socket=engine_socket,
|
||||
),
|
||||
storage=StorageSettings(
|
||||
path=storage_path,
|
||||
),
|
||||
project=ProjectSettings(
|
||||
default_path=project_path,
|
||||
modules_path=modules_path,
|
||||
),
|
||||
)
|
||||
|
||||
runner = Runner(settings)
|
||||
|
||||
context.obj = Context(
|
||||
runner=runner,
|
||||
project_path=project_path,
|
||||
)
|
||||
|
||||
|
||||
application.add_typer(mcp.application)
|
||||
application.add_typer(modules.application)
|
||||
application.add_typer(projects.application)
|
||||
1
fuzzforge-cli/src/fuzzforge_cli/commands/__init__.py
Normal file
1
fuzzforge-cli/src/fuzzforge_cli/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
527
fuzzforge-cli/src/fuzzforge_cli/commands/mcp.py
Normal file
527
fuzzforge-cli/src/fuzzforge_cli/commands/mcp.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""MCP server configuration commands for FuzzForge CLI.
|
||||
|
||||
This module provides commands for setting up MCP server connections
|
||||
with various AI agents (VS Code Copilot, Claude Code, etc.).
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
from rich.table import Table
|
||||
from typer import Argument, Context, Option, Typer
|
||||
|
||||
application: Typer = Typer(
|
||||
name="mcp",
|
||||
help="MCP server configuration commands.",
|
||||
)
|
||||
|
||||
|
||||
class AIAgent(StrEnum):
|
||||
"""Supported AI agents."""
|
||||
|
||||
COPILOT = "copilot" # GitHub Copilot in VS Code
|
||||
CLAUDE_DESKTOP = "claude-desktop" # Claude Desktop app
|
||||
CLAUDE_CODE = "claude-code" # Claude Code CLI (terminal)
|
||||
|
||||
|
||||
def _get_copilot_mcp_path() -> Path:
|
||||
"""Get the GitHub Copilot MCP configuration file path.
|
||||
|
||||
GitHub Copilot uses VS Code's mcp.json for MCP servers.
|
||||
|
||||
:returns: Path to the mcp.json file.
|
||||
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
||||
elif sys.platform == "win32":
|
||||
return Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "mcp.json"
|
||||
else: # Linux
|
||||
return Path.home() / ".config" / "Code" / "User" / "mcp.json"
|
||||
|
||||
|
||||
def _get_claude_desktop_mcp_path() -> Path:
|
||||
"""Get the Claude Desktop MCP configuration file path.
|
||||
|
||||
:returns: Path to the claude_desktop_config.json file.
|
||||
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
||||
elif sys.platform == "win32":
|
||||
return Path(os.environ.get("APPDATA", "")) / "Claude" / "claude_desktop_config.json"
|
||||
else: # Linux
|
||||
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
||||
|
||||
|
||||
def _get_claude_code_mcp_path(project_path: Path | None = None) -> Path:
|
||||
"""Get the Claude Code MCP configuration file path.
|
||||
|
||||
Claude Code uses .mcp.json in the project root for project-scoped servers.
|
||||
|
||||
:param project_path: Project directory path. If None, uses current directory.
|
||||
:returns: Path to the .mcp.json file.
|
||||
|
||||
"""
|
||||
if project_path:
|
||||
return project_path / ".mcp.json"
|
||||
return Path.cwd() / ".mcp.json"
|
||||
|
||||
|
||||
def _get_claude_code_user_mcp_path() -> Path:
|
||||
"""Get the Claude Code user-scoped MCP configuration file path.
|
||||
|
||||
:returns: Path to ~/.claude.json file.
|
||||
|
||||
"""
|
||||
return Path.home() / ".claude.json"
|
||||
|
||||
|
||||
def _detect_podman_socket() -> str:
|
||||
"""Auto-detect the Podman socket path.
|
||||
|
||||
:returns: Path to the Podman socket.
|
||||
|
||||
"""
|
||||
uid = os.getuid()
|
||||
socket_paths = [
|
||||
f"/run/user/{uid}/podman/podman.sock",
|
||||
"/run/podman/podman.sock",
|
||||
"/var/run/podman/podman.sock",
|
||||
]
|
||||
|
||||
for path in socket_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
# Default to user socket
|
||||
return f"/run/user/{uid}/podman/podman.sock"
|
||||
|
||||
|
||||
def _detect_docker_socket() -> str:
|
||||
"""Auto-detect the Docker socket path.
|
||||
|
||||
:returns: Path to the Docker socket.
|
||||
|
||||
"""
|
||||
socket_paths = [
|
||||
"/var/run/docker.sock",
|
||||
Path.home() / ".docker" / "run" / "docker.sock",
|
||||
]
|
||||
|
||||
for path in socket_paths:
|
||||
if Path(path).exists():
|
||||
return str(path)
|
||||
|
||||
return "/var/run/docker.sock"
|
||||
|
||||
|
||||
def _find_fuzzforge_root() -> Path:
|
||||
"""Find the FuzzForge installation root.
|
||||
|
||||
:returns: Path to fuzzforge-oss directory.
|
||||
|
||||
"""
|
||||
# Try to find from current file location
|
||||
current = Path(__file__).resolve()
|
||||
|
||||
# Walk up to find fuzzforge-oss root
|
||||
for parent in current.parents:
|
||||
if (parent / "fuzzforge-mcp").is_dir() and (parent / "fuzzforge-runner").is_dir():
|
||||
return parent
|
||||
|
||||
# Fall back to cwd
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def _generate_mcp_config(
|
||||
fuzzforge_root: Path,
|
||||
modules_path: Path,
|
||||
engine_type: str,
|
||||
engine_socket: str,
|
||||
) -> dict:
|
||||
"""Generate MCP server configuration.
|
||||
|
||||
:param fuzzforge_root: Path to fuzzforge-oss installation.
|
||||
:param modules_path: Path to the modules directory.
|
||||
:param engine_type: Container engine type (podman or docker).
|
||||
:param engine_socket: Container engine socket path.
|
||||
:returns: MCP configuration dictionary.
|
||||
|
||||
"""
|
||||
venv_python = fuzzforge_root / ".venv" / "bin" / "python"
|
||||
|
||||
# Use uv run if no venv, otherwise use venv python directly
|
||||
if venv_python.exists():
|
||||
command = str(venv_python)
|
||||
args = ["-m", "fuzzforge_mcp"]
|
||||
else:
|
||||
command = "uv"
|
||||
args = ["--directory", str(fuzzforge_root), "run", "fuzzforge-mcp"]
|
||||
|
||||
# Self-contained storage paths for FuzzForge containers
|
||||
# This isolates FuzzForge from system Podman and avoids snap issues
|
||||
fuzzforge_home = Path.home() / ".fuzzforge"
|
||||
graphroot = fuzzforge_home / "containers" / "storage"
|
||||
runroot = fuzzforge_home / "containers" / "run"
|
||||
|
||||
return {
|
||||
"type": "stdio",
|
||||
"command": command,
|
||||
"args": args,
|
||||
"cwd": str(fuzzforge_root),
|
||||
"env": {
|
||||
"FUZZFORGE_MODULES_PATH": str(modules_path),
|
||||
"FUZZFORGE_ENGINE__TYPE": engine_type,
|
||||
"FUZZFORGE_ENGINE__GRAPHROOT": str(graphroot),
|
||||
"FUZZFORGE_ENGINE__RUNROOT": str(runroot),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Show current MCP configuration status.",
|
||||
name="status",
|
||||
)
|
||||
def status(context: Context) -> None:
|
||||
"""Show MCP configuration status for all supported agents.
|
||||
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
console = Console()
|
||||
|
||||
table = Table(title="MCP Configuration Status")
|
||||
table.add_column("Agent", style="cyan")
|
||||
table.add_column("Config Path")
|
||||
table.add_column("Status")
|
||||
table.add_column("FuzzForge Configured")
|
||||
|
||||
fuzzforge_root = _find_fuzzforge_root()
|
||||
|
||||
agents = [
|
||||
("GitHub Copilot", _get_copilot_mcp_path(), "servers"),
|
||||
("Claude Desktop", _get_claude_desktop_mcp_path(), "mcpServers"),
|
||||
("Claude Code", _get_claude_code_user_mcp_path(), "mcpServers"),
|
||||
]
|
||||
|
||||
for name, config_path, servers_key in agents:
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text())
|
||||
servers = config.get(servers_key, {})
|
||||
has_fuzzforge = "fuzzforge" in servers
|
||||
table.add_row(
|
||||
name,
|
||||
str(config_path),
|
||||
"[green]✓ Exists[/green]",
|
||||
"[green]✓ Yes[/green]" if has_fuzzforge else "[yellow]✗ No[/yellow]",
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
table.add_row(
|
||||
name,
|
||||
str(config_path),
|
||||
"[red]✗ Invalid JSON[/red]",
|
||||
"[dim]-[/dim]",
|
||||
)
|
||||
else:
|
||||
table.add_row(
|
||||
name,
|
||||
str(config_path),
|
||||
"[dim]Not found[/dim]",
|
||||
"[dim]-[/dim]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Show detected environment
|
||||
console.print()
|
||||
console.print("[bold]Detected Environment:[/bold]")
|
||||
console.print(f" FuzzForge Root: {_find_fuzzforge_root()}")
|
||||
console.print(f" Podman Socket: {_detect_podman_socket()}")
|
||||
console.print(f" Docker Socket: {_detect_docker_socket()}")
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Generate MCP configuration for an AI agent.",
|
||||
name="generate",
|
||||
)
|
||||
def generate(
|
||||
context: Context,
|
||||
agent: Annotated[
|
||||
AIAgent,
|
||||
Argument(
|
||||
help="AI agent to generate config for (copilot, claude-desktop, or claude-code).",
|
||||
),
|
||||
],
|
||||
modules_path: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--modules",
|
||||
"-m",
|
||||
help="Path to the modules directory.",
|
||||
),
|
||||
] = None,
|
||||
engine: Annotated[
|
||||
str,
|
||||
Option(
|
||||
"--engine",
|
||||
"-e",
|
||||
help="Container engine (podman or docker).",
|
||||
),
|
||||
] = "podman",
|
||||
) -> None:
|
||||
"""Generate MCP configuration and print to stdout.
|
||||
|
||||
:param context: Typer context.
|
||||
:param agent: Target AI agent.
|
||||
:param modules_path: Override modules path.
|
||||
:param engine: Container engine type.
|
||||
|
||||
"""
|
||||
console = Console()
|
||||
fuzzforge_root = _find_fuzzforge_root()
|
||||
|
||||
# Use defaults if not specified
|
||||
resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules")
|
||||
|
||||
# Detect socket
|
||||
if engine == "podman":
|
||||
socket = _detect_podman_socket()
|
||||
else:
|
||||
socket = _detect_docker_socket()
|
||||
|
||||
# Generate config
|
||||
server_config = _generate_mcp_config(
|
||||
fuzzforge_root=fuzzforge_root,
|
||||
modules_path=resolved_modules,
|
||||
engine_type=engine,
|
||||
engine_socket=socket,
|
||||
)
|
||||
|
||||
# Format based on agent
|
||||
if agent == AIAgent.COPILOT:
|
||||
full_config = {"servers": {"fuzzforge": server_config}}
|
||||
else: # Claude Desktop or Claude Code
|
||||
full_config = {"mcpServers": {"fuzzforge": server_config}}
|
||||
|
||||
config_json = json.dumps(full_config, indent=4)
|
||||
|
||||
console.print(Panel(
|
||||
Syntax(config_json, "json", theme="monokai"),
|
||||
title=f"MCP Configuration for {agent.value}",
|
||||
))
|
||||
|
||||
# Show where to save it
|
||||
if agent == AIAgent.COPILOT:
|
||||
config_path = _get_copilot_mcp_path()
|
||||
elif agent == AIAgent.CLAUDE_CODE:
|
||||
config_path = _get_claude_code_mcp_path(fuzzforge_root)
|
||||
else: # Claude Desktop
|
||||
config_path = _get_claude_desktop_mcp_path()
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold]Save to:[/bold] {config_path}")
|
||||
console.print()
|
||||
console.print("[dim]Or run 'fuzzforge mcp install' to install automatically.[/dim]")
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Install MCP configuration for an AI agent.",
|
||||
name="install",
|
||||
)
|
||||
def install(
|
||||
context: Context,
|
||||
agent: Annotated[
|
||||
AIAgent,
|
||||
Argument(
|
||||
help="AI agent to install config for (copilot, claude-desktop, or claude-code).",
|
||||
),
|
||||
],
|
||||
modules_path: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--modules",
|
||||
"-m",
|
||||
help="Path to the modules directory.",
|
||||
),
|
||||
] = None,
|
||||
engine: Annotated[
|
||||
str,
|
||||
Option(
|
||||
"--engine",
|
||||
"-e",
|
||||
help="Container engine (podman or docker).",
|
||||
),
|
||||
] = "podman",
|
||||
force: Annotated[
|
||||
bool,
|
||||
Option(
|
||||
"--force",
|
||||
"-f",
|
||||
help="Overwrite existing fuzzforge configuration.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Install MCP configuration for the specified AI agent.
|
||||
|
||||
This will create or update the MCP configuration file, adding the
|
||||
fuzzforge server configuration.
|
||||
|
||||
:param context: Typer context.
|
||||
:param agent: Target AI agent.
|
||||
:param modules_path: Override modules path.
|
||||
:param engine: Container engine type.
|
||||
:param force: Overwrite existing configuration.
|
||||
|
||||
"""
|
||||
console = Console()
|
||||
fuzzforge_root = _find_fuzzforge_root()
|
||||
|
||||
# Determine config path
|
||||
if agent == AIAgent.COPILOT:
|
||||
config_path = _get_copilot_mcp_path()
|
||||
servers_key = "servers"
|
||||
elif agent == AIAgent.CLAUDE_CODE:
|
||||
config_path = _get_claude_code_user_mcp_path()
|
||||
servers_key = "mcpServers"
|
||||
else: # Claude Desktop
|
||||
config_path = _get_claude_desktop_mcp_path()
|
||||
servers_key = "mcpServers"
|
||||
|
||||
# Use defaults if not specified
|
||||
resolved_modules = modules_path or (fuzzforge_root / "fuzzforge-modules")
|
||||
|
||||
# Detect socket
|
||||
if engine == "podman":
|
||||
socket = _detect_podman_socket()
|
||||
else:
|
||||
socket = _detect_docker_socket()
|
||||
|
||||
# Generate server config
|
||||
server_config = _generate_mcp_config(
|
||||
fuzzforge_root=fuzzforge_root,
|
||||
modules_path=resolved_modules,
|
||||
engine_type=engine,
|
||||
engine_socket=socket,
|
||||
)
|
||||
|
||||
# Load existing config or create new
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing_config = json.loads(config_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
console.print(f"[red]Error: Invalid JSON in {config_path}[/red]")
|
||||
console.print("[dim]Please fix the file manually or delete it.[/dim]")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Check if fuzzforge already exists
|
||||
servers = existing_config.get(servers_key, {})
|
||||
if "fuzzforge" in servers and not force:
|
||||
console.print("[yellow]FuzzForge is already configured.[/yellow]")
|
||||
console.print("[dim]Use --force to overwrite existing configuration.[/dim]")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Add/update fuzzforge
|
||||
if servers_key not in existing_config:
|
||||
existing_config[servers_key] = {}
|
||||
existing_config[servers_key]["fuzzforge"] = server_config
|
||||
|
||||
full_config = existing_config
|
||||
else:
|
||||
# Create new config
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_config = {servers_key: {"fuzzforge": server_config}}
|
||||
|
||||
# Write config
|
||||
config_path.write_text(json.dumps(full_config, indent=4))
|
||||
|
||||
console.print(f"[green]✓ Installed FuzzForge MCP configuration for {agent.value}[/green]")
|
||||
console.print()
|
||||
console.print(f"[bold]Configuration file:[/bold] {config_path}")
|
||||
console.print()
|
||||
console.print("[bold]Settings:[/bold]")
|
||||
console.print(f" Modules Path: {resolved_modules}")
|
||||
console.print(f" Engine: {engine}")
|
||||
console.print(f" Socket: {socket}")
|
||||
console.print()
|
||||
|
||||
console.print("[bold]Next steps:[/bold]")
|
||||
if agent == AIAgent.COPILOT:
|
||||
console.print(" 1. Restart VS Code")
|
||||
console.print(" 2. Open Copilot Chat and look for FuzzForge tools")
|
||||
elif agent == AIAgent.CLAUDE_CODE:
|
||||
console.print(" 1. Run 'claude' from any directory")
|
||||
console.print(" 2. FuzzForge tools will be available")
|
||||
else: # Claude Desktop
|
||||
console.print(" 1. Restart Claude Desktop")
|
||||
console.print(" 2. The fuzzforge MCP server will be available")
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Remove MCP configuration for an AI agent.",
|
||||
name="uninstall",
|
||||
)
|
||||
def uninstall(
|
||||
context: Context,
|
||||
agent: Annotated[
|
||||
AIAgent,
|
||||
Argument(
|
||||
help="AI agent to remove config from (copilot, claude-desktop, or claude-code).",
|
||||
),
|
||||
],
|
||||
) -> None:
|
||||
"""Remove FuzzForge MCP configuration from the specified AI agent.
|
||||
|
||||
:param context: Typer context.
|
||||
:param agent: Target AI agent.
|
||||
|
||||
"""
|
||||
console = Console()
|
||||
fuzzforge_root = _find_fuzzforge_root()
|
||||
|
||||
# Determine config path
|
||||
if agent == AIAgent.COPILOT:
|
||||
config_path = _get_copilot_mcp_path()
|
||||
servers_key = "servers"
|
||||
elif agent == AIAgent.CLAUDE_CODE:
|
||||
config_path = _get_claude_code_user_mcp_path()
|
||||
servers_key = "mcpServers"
|
||||
else: # Claude Desktop
|
||||
config_path = _get_claude_desktop_mcp_path()
|
||||
servers_key = "mcpServers"
|
||||
|
||||
if not config_path.exists():
|
||||
console.print(f"[yellow]Configuration file not found: {config_path}[/yellow]")
|
||||
return
|
||||
|
||||
try:
|
||||
config = json.loads(config_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
console.print(f"[red]Error: Invalid JSON in {config_path}[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
servers = config.get(servers_key, {})
|
||||
if "fuzzforge" not in servers:
|
||||
console.print("[yellow]FuzzForge is not configured.[/yellow]")
|
||||
return
|
||||
|
||||
# Remove fuzzforge
|
||||
del servers["fuzzforge"]
|
||||
|
||||
# Write back
|
||||
config_path.write_text(json.dumps(config, indent=4))
|
||||
|
||||
console.print(f"[green]✓ Removed FuzzForge MCP configuration from {agent.value}[/green]")
|
||||
console.print()
|
||||
console.print("[dim]Restart your AI agent for changes to take effect.[/dim]")
|
||||
166
fuzzforge-cli/src/fuzzforge_cli/commands/modules.py
Normal file
166
fuzzforge-cli/src/fuzzforge_cli/commands/modules.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Module management commands for FuzzForge CLI."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typer import Argument, Context, Option, Typer
|
||||
|
||||
from fuzzforge_cli.context import get_project_path, get_runner
|
||||
|
||||
application: Typer = Typer(
|
||||
name="modules",
|
||||
help="Module management commands.",
|
||||
)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="List available modules.",
|
||||
name="list",
|
||||
)
|
||||
def list_modules(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""List all available modules.
|
||||
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
modules = runner.list_modules()
|
||||
|
||||
console = Console()
|
||||
|
||||
if not modules:
|
||||
console.print("[yellow]No modules found.[/yellow]")
|
||||
console.print(f" Modules directory: {runner.settings.modules_path}")
|
||||
return
|
||||
|
||||
table = Table(title="Available Modules")
|
||||
table.add_column("Identifier", style="cyan")
|
||||
table.add_column("Available")
|
||||
table.add_column("Description")
|
||||
|
||||
for module in modules:
|
||||
table.add_row(
|
||||
module.identifier,
|
||||
"✓" if module.available else "✗",
|
||||
module.description or "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Execute a module.",
|
||||
name="run",
|
||||
)
|
||||
def run_module(
|
||||
context: Context,
|
||||
module_identifier: Annotated[
|
||||
str,
|
||||
Argument(
|
||||
help="Identifier of the module to execute.",
|
||||
),
|
||||
],
|
||||
assets_path: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--assets",
|
||||
"-a",
|
||||
help="Path to input assets.",
|
||||
),
|
||||
] = None,
|
||||
config: Annotated[
|
||||
str | None,
|
||||
Option(
|
||||
"--config",
|
||||
"-c",
|
||||
help="Module configuration as JSON string.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Execute a module.
|
||||
|
||||
:param context: Typer context.
|
||||
:param module_identifier: Module to execute.
|
||||
:param assets_path: Optional path to input assets.
|
||||
:param config: Optional JSON configuration.
|
||||
|
||||
"""
|
||||
import json
|
||||
|
||||
runner = get_runner(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
configuration: dict[str, Any] | None = None
|
||||
if config:
|
||||
try:
|
||||
configuration = json.loads(config)
|
||||
except json.JSONDecodeError as e:
|
||||
console = Console()
|
||||
console.print(f"[red]✗[/red] Invalid JSON configuration: {e}")
|
||||
return
|
||||
|
||||
console = Console()
|
||||
console.print(f"[blue]→[/blue] Executing module: {module_identifier}")
|
||||
|
||||
async def execute() -> None:
|
||||
result = await runner.execute_module(
|
||||
module_identifier=module_identifier,
|
||||
project_path=project_path,
|
||||
configuration=configuration,
|
||||
assets_path=assets_path,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
console.print(f"[green]✓[/green] Module execution completed")
|
||||
console.print(f" Execution ID: {result.execution_id}")
|
||||
console.print(f" Results: {result.results_path}")
|
||||
else:
|
||||
console.print(f"[red]✗[/red] Module execution failed")
|
||||
console.print(f" Error: {result.error}")
|
||||
|
||||
asyncio.run(execute())
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Show module information.",
|
||||
name="info",
|
||||
)
|
||||
def module_info(
|
||||
context: Context,
|
||||
module_identifier: Annotated[
|
||||
str,
|
||||
Argument(
|
||||
help="Identifier of the module.",
|
||||
),
|
||||
],
|
||||
) -> None:
|
||||
"""Show information about a specific module.
|
||||
|
||||
:param context: Typer context.
|
||||
:param module_identifier: Module to get info for.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
module = runner.get_module_info(module_identifier)
|
||||
|
||||
console = Console()
|
||||
|
||||
if module is None:
|
||||
console.print(f"[red]✗[/red] Module not found: {module_identifier}")
|
||||
return
|
||||
|
||||
table = Table(title=f"Module: {module.identifier}")
|
||||
table.add_column("Property", style="cyan")
|
||||
table.add_column("Value")
|
||||
|
||||
table.add_row("Identifier", module.identifier)
|
||||
table.add_row("Available", "Yes" if module.available else "No")
|
||||
table.add_row("Description", module.description or "-")
|
||||
table.add_row("Version", module.version or "-")
|
||||
|
||||
console.print(table)
|
||||
186
fuzzforge-cli/src/fuzzforge_cli/commands/projects.py
Normal file
186
fuzzforge-cli/src/fuzzforge_cli/commands/projects.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Project management commands for FuzzForge CLI."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typer import Argument, Context, Option, Typer
|
||||
|
||||
from fuzzforge_cli.context import get_project_path, get_runner
|
||||
|
||||
application: Typer = Typer(
|
||||
name="project",
|
||||
help="Project management commands.",
|
||||
)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Initialize a new FuzzForge project.",
|
||||
name="init",
|
||||
)
|
||||
def init_project(
|
||||
context: Context,
|
||||
path: Annotated[
|
||||
Path | None,
|
||||
Argument(
|
||||
help="Path to initialize the project in. Defaults to current directory.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Initialize a new FuzzForge project.
|
||||
|
||||
Creates the necessary storage directories for the project.
|
||||
|
||||
:param context: Typer context.
|
||||
:param path: Path to initialize (defaults to current directory).
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
project_path = path or get_project_path(context)
|
||||
|
||||
storage_path = runner.init_project(project_path)
|
||||
|
||||
console = Console()
|
||||
console.print(f"[green]✓[/green] Project initialized at {project_path}")
|
||||
console.print(f" Storage: {storage_path}")
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Set project assets.",
|
||||
name="assets",
|
||||
)
|
||||
def set_assets(
|
||||
context: Context,
|
||||
assets_path: Annotated[
|
||||
Path,
|
||||
Argument(
|
||||
help="Path to assets file or directory.",
|
||||
),
|
||||
],
|
||||
) -> None:
|
||||
"""Set the initial assets for the project.
|
||||
|
||||
:param context: Typer context.
|
||||
:param assets_path: Path to assets.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
stored_path = runner.set_project_assets(project_path, assets_path)
|
||||
|
||||
console = Console()
|
||||
console.print(f"[green]✓[/green] Assets stored from {assets_path}")
|
||||
console.print(f" Location: {stored_path}")
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Show project information.",
|
||||
name="info",
|
||||
)
|
||||
def show_info(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""Show information about the current project.
|
||||
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
executions = runner.list_executions(project_path)
|
||||
assets_path = runner.storage.get_project_assets_path(project_path)
|
||||
|
||||
console = Console()
|
||||
table = Table(title=f"Project: {project_path.name}")
|
||||
table.add_column("Property", style="cyan")
|
||||
table.add_column("Value")
|
||||
|
||||
table.add_row("Path", str(project_path))
|
||||
table.add_row("Has Assets", "Yes" if assets_path else "No")
|
||||
table.add_row("Assets Path", str(assets_path) if assets_path else "-")
|
||||
table.add_row("Executions", str(len(executions)))
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="List all executions.",
|
||||
name="executions",
|
||||
)
|
||||
def list_executions(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""List all executions for the project.
|
||||
|
||||
:param context: Typer context.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
executions = runner.list_executions(project_path)
|
||||
|
||||
console = Console()
|
||||
|
||||
if not executions:
|
||||
console.print("[yellow]No executions found.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Executions")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Has Results")
|
||||
|
||||
for exec_id in executions:
|
||||
has_results = runner.get_execution_results(project_path, exec_id) is not None
|
||||
table.add_row(exec_id, "✓" if has_results else "-")
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@application.command(
|
||||
help="Get execution results.",
|
||||
name="results",
|
||||
)
|
||||
def get_results(
|
||||
context: Context,
|
||||
execution_id: Annotated[
|
||||
str,
|
||||
Argument(
|
||||
help="Execution ID to get results for.",
|
||||
),
|
||||
],
|
||||
extract_to: Annotated[
|
||||
Path | None,
|
||||
Option(
|
||||
"--extract",
|
||||
"-x",
|
||||
help="Extract results to this directory.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Get results for a specific execution.
|
||||
|
||||
:param context: Typer context.
|
||||
:param execution_id: Execution ID.
|
||||
:param extract_to: Optional directory to extract to.
|
||||
|
||||
"""
|
||||
runner = get_runner(context)
|
||||
project_path = get_project_path(context)
|
||||
|
||||
results_path = runner.get_execution_results(project_path, execution_id)
|
||||
|
||||
console = Console()
|
||||
|
||||
if results_path is None:
|
||||
console.print(f"[red]✗[/red] No results found for execution {execution_id}")
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Results: {results_path}")
|
||||
|
||||
if extract_to:
|
||||
extracted = runner.extract_results(results_path, extract_to)
|
||||
console.print(f" Extracted to: {extracted}")
|
||||
64
fuzzforge-cli/src/fuzzforge_cli/context.py
Normal file
64
fuzzforge-cli/src/fuzzforge_cli/context.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""FuzzForge CLI context management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from fuzzforge_runner import Runner, Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typer import Context as TyperContext
|
||||
|
||||
|
||||
class Context:
|
||||
"""CLI context holding the runner instance and settings."""
|
||||
|
||||
_runner: Runner
|
||||
_project_path: Path
|
||||
|
||||
def __init__(self, runner: Runner, project_path: Path) -> None:
|
||||
"""Initialize an instance of the class.
|
||||
|
||||
:param runner: FuzzForge runner instance.
|
||||
:param project_path: Path to the current project.
|
||||
|
||||
"""
|
||||
self._runner = runner
|
||||
self._project_path = project_path
|
||||
|
||||
def get_runner(self) -> Runner:
|
||||
"""Get the runner instance.
|
||||
|
||||
:return: Runner instance.
|
||||
|
||||
"""
|
||||
return self._runner
|
||||
|
||||
def get_project_path(self) -> Path:
|
||||
"""Get the current project path.
|
||||
|
||||
:return: Project path.
|
||||
|
||||
"""
|
||||
return self._project_path
|
||||
|
||||
|
||||
def get_runner(context: TyperContext) -> Runner:
|
||||
"""Get runner from Typer context.
|
||||
|
||||
:param context: Typer context.
|
||||
:return: Runner instance.
|
||||
|
||||
"""
|
||||
return cast("Context", context.obj).get_runner()
|
||||
|
||||
|
||||
def get_project_path(context: TyperContext) -> Path:
|
||||
"""Get project path from Typer context.
|
||||
|
||||
:param context: Typer context.
|
||||
:return: Project path.
|
||||
|
||||
"""
|
||||
return cast("Context", context.obj).get_project_path()
|
||||
18
fuzzforge-cli/src/fuzzforge_cli/utilities.py
Normal file
18
fuzzforge-cli/src/fuzzforge_cli/utilities.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""CLI utility functions."""
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typer import Exit
|
||||
|
||||
|
||||
def on_error(message: str) -> None:
|
||||
"""Display an error message and exit.
|
||||
|
||||
:param message: Error message to display.
|
||||
|
||||
"""
|
||||
table = Table()
|
||||
table.add_column("Error")
|
||||
table.add_row(message)
|
||||
Console().print(table)
|
||||
raise Exit(code=1)
|
||||
1
fuzzforge-cli/tests/__init__.py
Normal file
1
fuzzforge-cli/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
1
fuzzforge-cli/tests/conftest.py
Normal file
1
fuzzforge-cli/tests/conftest.py
Normal file
@@ -0,0 +1 @@
|
||||
"""TODO."""
|
||||
Reference in New Issue
Block a user