feat: FuzzForge AI - complete rewrite for OSS release

This commit is contained in:
AFredefon
2026-01-30 09:57:48 +01:00
commit b46f050aef
226 changed files with 12943 additions and 0 deletions

37
fuzzforge-cli/Makefile Normal file
View 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
View File

@@ -0,0 +1,3 @@
# FuzzForge CLI
...

6
fuzzforge-cli/mypy.ini Normal file
View File

@@ -0,0 +1,6 @@
[mypy]
plugins = pydantic.mypy
strict = True
warn_unused_ignores = True
warn_redundant_casts = True
warn_return_any = True

View 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
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

15
fuzzforge-cli/ruff.toml Normal file
View 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
]

View File

@@ -0,0 +1 @@
"""TODO."""

View File

@@ -0,0 +1,12 @@
"""TODO."""
from fuzzforge_cli.application import application
def main() -> None:
"""TODO."""
application()
if __name__ == "__main__":
main()

View 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)

View File

@@ -0,0 +1 @@
"""TODO."""

View 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]")

View 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)

View 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}")

View 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()

View 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)

View File

@@ -0,0 +1 @@
"""TODO."""

View File

@@ -0,0 +1 @@
"""TODO."""