diff --git a/README.md b/README.md index 9a71461..553b3bf 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ uv sync cp .env.example .env ``` +`uv sync` installs the `kai-security` command (the distribution is published as +`kai-security`; the command and import package are `kai`). + Common developer commands are available through `make`: ```bash @@ -33,6 +36,25 @@ make typecheck make run REPO_PATH=/path/to/target ``` +## Command-line interface + +```bash +# Audit a repository you're authorized to test (setup → exploit pipeline) +uv run kai-security audit --repo-path /path/to/target --verbose + +# Open a finished run as an interactive HTML report (findings + agent trace) +uv run kai-security view output/state/ --open + +# Render a run's findings — Markdown to stdout, or a styled HTML document +uv run kai-security report output/state/ +uv run kai-security report output/state/ --format html -o report.html +``` + +`kai-security audit` is the friendly alias for the full pipeline; `kai-security pipeline` and +`kai-security agent` expose the complete interface documented under [Usage](#usage) +(equivalently `uv run python -m kai.main ...`). Run `kai-security -h` for +per-command options. + ### API keys | Key | Required | Used by | diff --git a/pyproject.toml b/pyproject.toml index d185f07..87427d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,17 @@ requires = ["uv_build>=0.8.17,<0.9.0"] build-backend = "uv_build" +# The published distribution is `kai-security`, but the import packages stay +# `kai` (domain) and `ra` (framework). Ship BOTH — `kai` imports `ra`, so a +# wheel with only `kai` is broken. Listing them also decouples the wheel from +# the dotted distribution name. The bare `kai` name on PyPI is reserved for the +# future umbrella dispatcher. +[tool.uv.build-backend] +module-name = ["kai", "ra"] +module-root = "src" + [project] -name = "kai" +name = "kai-security" version = "0.1.0" description = "Automated vulnerability discovery, verification, and patching using recursive language models." readme = "README.md" @@ -51,6 +60,9 @@ Homepage = "https://github.com/firstbatchxyz/kai-security" Repository = "https://github.com/firstbatchxyz/kai-security" Issues = "https://github.com/firstbatchxyz/kai-security/issues" +[project.scripts] +kai-security = "kai.cli:main" + [project.optional-dependencies] dev = [ "pytest>=9.0.2", diff --git a/src/kai/cli.py b/src/kai/cli.py new file mode 100644 index 0000000..4efae12 --- /dev/null +++ b/src/kai/cli.py @@ -0,0 +1,71 @@ +"""The ``kai`` command-line entry point. + +A thin dispatcher over the existing modules, giving the friendly verbs the +docs promise: + + kai audit analyze a repository (setup → exploit pipeline) + kai view open a finished run as interactive HTML + kai report render a run's findings (Markdown, or --format html) + +``kai pipeline`` / ``kai agent`` remain available as direct aliases into the +full :mod:`kai.main` interface. The distribution is published as +``kai-security``; the command and the import package stay ``kai``. +""" + +from __future__ import annotations + +import sys + +_USAGE = """\ +kai-security — automated vulnerability discovery, verification, and patching + +usage: kai-security [options] + +commands: + audit Analyze a repository for vulnerabilities (setup → exploit) + view Open a finished run as interactive HTML (findings + trace) + report Render a run's findings as Markdown (default) or HTML + + pipeline Full pipeline interface (kai audit is the friendly alias) + agent Run a single agent + +Run `kai-security -h` for command-specific options. +""" + + +def main(argv: list[str] | None = None) -> int: + """Dispatch a ``kai`` subcommand. Returns a process exit code.""" + + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in ("-h", "--help", "help"): + sys.stdout.write(_USAGE) + return 0 + + command, rest = argv[0], argv[1:] + + if command in ("audit", "pipeline"): + from kai.main import main as kai_main + + kai_main(["pipeline", *rest]) + return 0 + if command == "agent": + from kai.main import main as kai_main + + kai_main(["agent", *rest]) + return 0 + if command == "view": + from kai.viewer.__main__ import main as view_main + + return view_main(rest) + if command == "report": + from kai.report import main as report_main + + return report_main(rest) + + sys.stderr.write(f"kai-security: unknown command {command!r}\n\n") + sys.stdout.write(_USAGE) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..22cf681 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,69 @@ +"""Tests for the unified ``kai`` CLI dispatcher.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from kai import cli + + +def _write_run(dir_path: Path) -> None: + exploits = [ + { + "exploit_id": "e1", "status": "verified", "confirmed": True, + "hypothesis": "Reentrancy in withdraw drains the vault.", + "file": "Vault.sol", "function": "withdraw", "category": "active_exploit", + "severity": "critical", "cvss_score": 9.1, + } + ] + (dir_path / "exploits.json").write_text(json.dumps(exploits), encoding="utf-8") + + +def test_help_and_no_args_print_usage(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main([]) == 0 + assert "usage: kai " in capsys.readouterr().out + assert cli.main(["--help"]) == 0 + assert "audit" in capsys.readouterr().out + + +def test_unknown_command_returns_2(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["bogus"]) == 2 + err = capsys.readouterr().err + assert "unknown command 'bogus'" in err + + +@pytest.mark.parametrize( + "command,expected", + [ + (["audit", "/repo", "--verbose"], ["pipeline", "/repo", "--verbose"]), + (["pipeline", "--recipe", "r.json"], ["pipeline", "--recipe", "r.json"]), + (["agent", "setup", "--input", "{}"], ["agent", "setup", "--input", "{}"]), + ], +) +def test_audit_pipeline_agent_delegate_to_kai_main( + command: list[str], expected: list[str], monkeypatch: pytest.MonkeyPatch +) -> None: + captured: list[list[str]] = [] + monkeypatch.setattr("kai.main.main", lambda argv: captured.append(argv)) + assert cli.main(command) == 0 + assert captured == [expected] + + +def test_view_delegates_and_writes_html(tmp_path: Path) -> None: + _write_run(tmp_path) + out = tmp_path / "v.html" + assert cli.main(["view", str(tmp_path), "-o", str(out)]) == 0 + assert out.exists() and out.read_text(encoding="utf-8").startswith("") + + +def test_report_delegates(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + _write_run(tmp_path) + assert cli.main(["report", str(tmp_path)]) == 0 + assert "Security findings" in capsys.readouterr().out + + out = tmp_path / "r.html" + assert cli.main(["report", str(tmp_path), "--format", "html", "-o", str(out)]) == 0 + assert out.exists() diff --git a/uv.lock b/uv.lock index 73752a4..ce4ac16 100644 --- a/uv.lock +++ b/uv.lock @@ -691,7 +691,7 @@ wheels = [ ] [[package]] -name = "kai" +name = "kai-security" version = "0.1.0" source = { editable = "." } dependencies = [