luthien-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,61 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .coverage
9
+ .coverage.cavil.*
10
+ coverage.json
11
+
12
+ # Virtual environments
13
+ .venv
14
+
15
+ # Logs
16
+ logs/
17
+
18
+ # Secrets
19
+ .env
20
+ .env.*
21
+ !.env.example
22
+
23
+ # Development cache files
24
+ .dev_deps_synced
25
+ .last_update
26
+ scratch/
27
+ _scratch/
28
+
29
+ # Replay callback exports
30
+ dev/replay_logs*.jsonl
31
+ dev/litellm_replay_logs/
32
+
33
+ # Observability runtime data
34
+ observability/data/
35
+
36
+ # Test/debug output
37
+ debug_multitool_streaming.json
38
+ debug_multitool_nonstreaming.json
39
+
40
+ # macOS
41
+ .DS_Store
42
+ .AppleDouble
43
+ .LSOverride
44
+
45
+ # IDEs
46
+ .vscode/
47
+ .idea/
48
+ *.swp
49
+ *.swo
50
+ *~
51
+
52
+ # Python tools cache
53
+ .pytest_cache/
54
+ .ruff_cache/
55
+ .mypy_cache/
56
+ .pytype/
57
+
58
+ # worktree meta
59
+ .worktree-purpose
60
+ .playwright-mcp/
61
+ .worktrees/
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: luthien-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for managing luthien-proxy gateways
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1.0
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
12
+ Requires-Dist: pytest-httpx>=0.35.0; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,61 @@
1
+ # Luthien CLI
2
+
3
+ A standalone CLI tool for managing and interacting with [luthien-proxy](https://github.com/LuthienResearch/luthien-proxy) gateways.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pipx install luthien-cli
9
+ # or for development:
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Configure your gateway
17
+ luthien config set gateway.url http://localhost:8000
18
+ luthien config set gateway.api_key sk-your-proxy-key
19
+ luthien config set gateway.admin_key admin-your-key
20
+
21
+ # Check gateway status
22
+ luthien status
23
+
24
+ # Launch Claude Code through the proxy
25
+ luthien claude
26
+ luthien claude -- --model opus
27
+
28
+ # Manage local stack (requires repo_path)
29
+ luthien config set local.repo_path /path/to/luthien-proxy
30
+ luthien up
31
+ luthien logs -f
32
+ luthien down
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ | Command | Description |
38
+ |---------|-------------|
39
+ | `luthien status` | Show gateway health, active policy, and auth mode |
40
+ | `luthien claude [args...]` | Launch Claude Code routed through the gateway |
41
+ | `luthien up [--follow]` | Start the local docker-compose stack |
42
+ | `luthien down` | Stop the local stack |
43
+ | `luthien logs [-f] [-n N]` | View gateway logs |
44
+ | `luthien config show` | Display current configuration |
45
+ | `luthien config set <key> <value>` | Update a config value |
46
+
47
+ ## Configuration
48
+
49
+ Config is stored at `~/.luthien/config.toml`:
50
+
51
+ ```toml
52
+ [gateway]
53
+ url = "http://localhost:8000"
54
+ api_key = "sk-your-proxy-key"
55
+ admin_key = "admin-your-key"
56
+
57
+ [local]
58
+ repo_path = "/path/to/luthien-proxy"
59
+ ```
60
+
61
+ Config keys: `gateway.url`, `gateway.api_key`, `gateway.admin_key`, `local.repo_path`
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "luthien-cli"
3
+ version = "0.1.0"
4
+ description = "CLI tool for managing luthien-proxy gateways"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "click>=8.1.0",
8
+ "httpx>=0.27.0",
9
+ "tomli-w>=1.0.0",
10
+ "rich>=13.0.0",
11
+ ]
12
+
13
+ [project.scripts]
14
+ luthien = "luthien_cli.main:cli"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/luthien_cli"]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=8.0",
26
+ "pytest-cov>=4.0",
27
+ "pytest-httpx>=0.35.0",
28
+ ]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
File without changes
File without changes
@@ -0,0 +1,45 @@
1
+ """luthien claude -- launch Claude Code through the gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from luthien_cli.config import DEFAULT_CONFIG_PATH, load_config
12
+
13
+
14
+ @click.command(context_settings={"ignore_unknown_options": True})
15
+ @click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
16
+ def claude(claude_args: tuple[str, ...]):
17
+ """Launch Claude Code routed through the configured gateway.
18
+
19
+ All arguments after 'claude' are passed through to Claude Code.
20
+ """
21
+ console = Console()
22
+ config = load_config(DEFAULT_CONFIG_PATH)
23
+
24
+ if not config.api_key:
25
+ console.print(
26
+ "[red]No API key configured. Run: luthien config set gateway.api_key <key>[/red]"
27
+ )
28
+ raise SystemExit(1)
29
+
30
+ claude_path = shutil.which("claude")
31
+ if not claude_path:
32
+ console.print(
33
+ "[red]Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-cli[/red]"
34
+ )
35
+ raise SystemExit(1)
36
+
37
+ gateway_url = config.gateway_url.rstrip("/") + "/"
38
+
39
+ env = os.environ.copy()
40
+ env["ANTHROPIC_BASE_URL"] = gateway_url
41
+ env["ANTHROPIC_API_KEY"] = config.api_key
42
+
43
+ console.print(f"[blue]Routing Claude Code through {config.gateway_url}[/blue]")
44
+
45
+ os.execvpe("claude", ["claude", *claude_args], env)
@@ -0,0 +1,65 @@
1
+ """luthien config -- view and edit CLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from luthien_cli.config import DEFAULT_CONFIG_PATH, load_config, save_config
10
+
11
+ FIELD_MAP = {
12
+ "gateway.url": "gateway_url",
13
+ "gateway.api_key": "api_key",
14
+ "gateway.admin_key": "admin_key",
15
+ "local.repo_path": "repo_path",
16
+ }
17
+
18
+
19
+ @click.group()
20
+ def config():
21
+ """View or edit luthien CLI configuration."""
22
+
23
+
24
+ @config.command()
25
+ def show():
26
+ """Display current configuration."""
27
+ console = Console()
28
+ cfg = load_config(DEFAULT_CONFIG_PATH)
29
+
30
+ table = Table(title=f"Config ({DEFAULT_CONFIG_PATH})")
31
+ table.add_column("Key", style="bold")
32
+ table.add_column("Value")
33
+
34
+ table.add_row("gateway.url", cfg.gateway_url)
35
+ table.add_row("gateway.api_key", _mask(cfg.api_key))
36
+ table.add_row("gateway.admin_key", _mask(cfg.admin_key))
37
+ table.add_row("local.repo_path", cfg.repo_path or "[dim]not set[/dim]")
38
+
39
+ console.print(table)
40
+
41
+
42
+ @config.command("set")
43
+ @click.argument("key")
44
+ @click.argument("value")
45
+ def set_value(key: str, value: str):
46
+ """Set a config value. Keys: gateway.url, gateway.api_key, gateway.admin_key, local.repo_path"""
47
+ console = Console()
48
+ cfg = load_config(DEFAULT_CONFIG_PATH)
49
+
50
+ if key not in FIELD_MAP:
51
+ console.print(f"[red]Unknown key: {key}[/red]")
52
+ console.print(f"Valid keys: {', '.join(FIELD_MAP.keys())}")
53
+ raise SystemExit(1)
54
+
55
+ setattr(cfg, FIELD_MAP[key], value)
56
+ save_config(cfg, DEFAULT_CONFIG_PATH)
57
+ console.print(f"[green]Set {key} = {value}[/green]")
58
+
59
+
60
+ def _mask(value: str | None) -> str:
61
+ if not value:
62
+ return "[dim]not set[/dim]"
63
+ if len(value) <= 8:
64
+ return "****"
65
+ return value[:4] + "..." + value[-4:]
@@ -0,0 +1,33 @@
1
+ """luthien logs -- view gateway logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from luthien_cli.config import DEFAULT_CONFIG_PATH, load_config
11
+
12
+
13
+ @click.command()
14
+ @click.option("--tail", "-n", default=None, type=int, help="Number of lines to show")
15
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output")
16
+ def logs(tail: int | None, follow: bool):
17
+ """View gateway logs (requires local repo_path configured)."""
18
+ console = Console()
19
+ config = load_config(DEFAULT_CONFIG_PATH)
20
+
21
+ if not config.repo_path:
22
+ console.print(
23
+ "[red]No repo_path configured. Set it with: luthien config set local.repo_path <path>[/red]"
24
+ )
25
+ raise SystemExit(1)
26
+
27
+ cmd = ["docker", "compose", "logs", "gateway"]
28
+ if tail is not None:
29
+ cmd.extend(["--tail", str(tail)])
30
+ if follow:
31
+ cmd.append("-f")
32
+
33
+ subprocess.run(cmd, cwd=config.repo_path)
@@ -0,0 +1,53 @@
1
+ """luthien status -- show gateway state."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from luthien_cli.config import load_config
8
+ from luthien_cli.gateway_client import GatewayClient, GatewayError
9
+
10
+
11
+ def make_client() -> GatewayClient:
12
+ config = load_config()
13
+ return GatewayClient(
14
+ base_url=config.gateway_url,
15
+ api_key=config.api_key,
16
+ admin_key=config.admin_key,
17
+ )
18
+
19
+
20
+ @click.command()
21
+ def status():
22
+ """Show gateway health, active policy, and auth mode."""
23
+ console = Console()
24
+ client = make_client()
25
+
26
+ try:
27
+ health = client.health()
28
+ except GatewayError as e:
29
+ console.print(f"[red]{e}[/red]")
30
+ raise SystemExit(1)
31
+
32
+ table = Table(title="Gateway Status")
33
+ table.add_column("Property", style="bold")
34
+ table.add_column("Value")
35
+
36
+ table.add_row("URL", client.base_url)
37
+ table.add_row("Status", f"[green]{health['status']}[/green]")
38
+ table.add_row("Version", health.get("version", "unknown"))
39
+
40
+ try:
41
+ policy = client.get_current_policy()
42
+ table.add_row("Policy", policy["policy"])
43
+ table.add_row("Policy Class", policy["class_ref"])
44
+ except GatewayError:
45
+ table.add_row("Policy", "[yellow]unavailable (no admin key?)[/yellow]")
46
+
47
+ try:
48
+ auth = client.get_auth_config()
49
+ table.add_row("Auth Mode", auth["auth_mode"])
50
+ except GatewayError:
51
+ table.add_row("Auth Mode", "[yellow]unavailable[/yellow]")
52
+
53
+ console.print(table)
@@ -0,0 +1,89 @@
1
+ """luthien up/down -- manage local docker-compose stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import time
7
+
8
+ import click
9
+ import httpx
10
+ from rich.console import Console
11
+
12
+ from luthien_cli.config import DEFAULT_CONFIG_PATH, load_config, save_config
13
+
14
+
15
+ def wait_for_healthy(url: str, timeout: int = 60) -> bool:
16
+ """Poll gateway /health until it responds or timeout."""
17
+ deadline = time.time() + timeout
18
+ while time.time() < deadline:
19
+ try:
20
+ r = httpx.get(f"{url}/health", timeout=5.0)
21
+ if r.status_code == 200:
22
+ return True
23
+ except (httpx.ConnectError, httpx.TimeoutException):
24
+ pass
25
+ time.sleep(2)
26
+ return False
27
+
28
+
29
+ @click.command()
30
+ @click.option("--follow", "-f", is_flag=True, help="Tail gateway logs after startup")
31
+ def up(follow: bool):
32
+ """Start the local luthien-proxy stack (db, redis, gateway)."""
33
+ console = Console()
34
+ config = load_config(DEFAULT_CONFIG_PATH)
35
+
36
+ if not config.repo_path:
37
+ repo = click.prompt("Path to luthien-proxy repo", type=str)
38
+ config.repo_path = repo
39
+ save_config(config, DEFAULT_CONFIG_PATH)
40
+
41
+ console.print(f"[blue]Starting stack in {config.repo_path}[/blue]")
42
+
43
+ result = subprocess.run(
44
+ ["docker", "compose", "up", "-d"],
45
+ cwd=config.repo_path,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ if result.returncode != 0:
50
+ console.print(f"[red]docker compose up failed:[/red]\n{result.stderr}")
51
+ raise SystemExit(1)
52
+
53
+ console.print("[yellow]Waiting for gateway to be healthy...[/yellow]")
54
+ if wait_for_healthy(config.gateway_url):
55
+ console.print(f"[green]Gateway is healthy at {config.gateway_url}[/green]")
56
+ else:
57
+ console.print("[red]Gateway did not become healthy within 60s[/red]")
58
+ raise SystemExit(1)
59
+
60
+ if follow:
61
+ subprocess.run(
62
+ ["docker", "compose", "logs", "-f", "gateway"],
63
+ cwd=config.repo_path,
64
+ )
65
+
66
+
67
+ @click.command()
68
+ def down():
69
+ """Stop the local luthien-proxy stack."""
70
+ console = Console()
71
+ config = load_config(DEFAULT_CONFIG_PATH)
72
+
73
+ if not config.repo_path:
74
+ console.print("[red]No repo_path configured. Nothing to stop.[/red]")
75
+ raise SystemExit(1)
76
+
77
+ console.print(f"[blue]Stopping stack in {config.repo_path}[/blue]")
78
+
79
+ result = subprocess.run(
80
+ ["docker", "compose", "down"],
81
+ cwd=config.repo_path,
82
+ capture_output=True,
83
+ text=True,
84
+ )
85
+ if result.returncode != 0:
86
+ console.print(f"[red]docker compose down failed:[/red]\n{result.stderr}")
87
+ raise SystemExit(1)
88
+
89
+ console.print("[green]Stack stopped.[/green]")
@@ -0,0 +1,60 @@
1
+ """Config management for ~/.luthien/config.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import tomli_w
10
+
11
+ DEFAULT_CONFIG_DIR = Path.home() / ".luthien"
12
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
13
+
14
+
15
+ @dataclass
16
+ class LuthienConfig:
17
+ gateway_url: str = "http://localhost:8000"
18
+ api_key: str | None = None
19
+ admin_key: str | None = None
20
+ repo_path: str | None = None
21
+
22
+
23
+ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> LuthienConfig:
24
+ """Load config from TOML file. Returns defaults if file doesn't exist."""
25
+ if not path.exists():
26
+ return LuthienConfig()
27
+
28
+ with open(path, "rb") as f:
29
+ data = tomllib.load(f)
30
+
31
+ gateway = data.get("gateway", {})
32
+ local = data.get("local", {})
33
+
34
+ return LuthienConfig(
35
+ gateway_url=gateway.get("url", "http://localhost:8000"),
36
+ api_key=gateway.get("api_key"),
37
+ admin_key=gateway.get("admin_key"),
38
+ repo_path=local.get("repo_path"),
39
+ )
40
+
41
+
42
+ def save_config(config: LuthienConfig, path: Path = DEFAULT_CONFIG_PATH) -> None:
43
+ """Save config to TOML file. Creates parent directories if needed."""
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+
46
+ data: dict = {
47
+ "gateway": {
48
+ "url": config.gateway_url,
49
+ },
50
+ "local": {},
51
+ }
52
+ if config.api_key:
53
+ data["gateway"]["api_key"] = config.api_key
54
+ if config.admin_key:
55
+ data["gateway"]["admin_key"] = config.admin_key
56
+ if config.repo_path:
57
+ data["local"]["repo_path"] = config.repo_path
58
+
59
+ with open(path, "wb") as f:
60
+ tomli_w.dump(data, f)
@@ -0,0 +1,51 @@
1
+ """HTTP client for luthien-proxy gateway APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class GatewayError(Exception):
11
+ """Error communicating with gateway."""
12
+
13
+
14
+ class GatewayClient:
15
+ """Thin HTTP client for gateway admin/health APIs."""
16
+
17
+ def __init__(self, base_url: str, api_key: str | None = None, admin_key: str | None = None):
18
+ self.base_url = base_url.rstrip("/")
19
+ self.api_key = api_key
20
+ self.admin_key = admin_key
21
+
22
+ def _admin_headers(self) -> dict[str, str]:
23
+ headers: dict[str, str] = {}
24
+ if self.admin_key:
25
+ headers["x-admin-key"] = self.admin_key
26
+ return headers
27
+
28
+ def _get(self, path: str, admin: bool = False) -> dict[str, Any]:
29
+ headers = self._admin_headers() if admin else {}
30
+ try:
31
+ response = httpx.get(f"{self.base_url}{path}", headers=headers, timeout=10.0)
32
+ except httpx.ConnectError:
33
+ raise GatewayError(f"Cannot connect to gateway at {self.base_url}")
34
+ except httpx.TimeoutException:
35
+ raise GatewayError(f"Gateway at {self.base_url} timed out")
36
+
37
+ if response.status_code == 401:
38
+ raise GatewayError("Authentication failed — check your admin_key")
39
+ if response.status_code == 403:
40
+ raise GatewayError("Forbidden — admin access required")
41
+ response.raise_for_status()
42
+ return response.json()
43
+
44
+ def health(self) -> dict[str, Any]:
45
+ return self._get("/health")
46
+
47
+ def get_current_policy(self) -> dict[str, Any]:
48
+ return self._get("/api/admin/policy/current", admin=True)
49
+
50
+ def get_auth_config(self) -> dict[str, Any]:
51
+ return self._get("/api/admin/auth/config", admin=True)
@@ -0,0 +1,27 @@
1
+ """CLI entry point."""
2
+
3
+ import click
4
+
5
+
6
+ @click.group()
7
+ @click.version_option(version="0.1.0")
8
+ def cli():
9
+ """Luthien -- manage and interact with luthien-proxy gateways."""
10
+
11
+
12
+ from luthien_cli.commands.claude import claude
13
+ from luthien_cli.commands.config_cmd import config
14
+ from luthien_cli.commands.logs import logs
15
+ from luthien_cli.commands.status import status
16
+ from luthien_cli.commands.up import down, up
17
+
18
+ cli.add_command(claude)
19
+ cli.add_command(config)
20
+ cli.add_command(down)
21
+ cli.add_command(logs)
22
+ cli.add_command(status)
23
+ cli.add_command(up)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ cli()
File without changes
@@ -0,0 +1,55 @@
1
+ """Tests for claude command."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from luthien_cli.main import cli
8
+
9
+
10
+ def test_claude_sets_env_and_execs(tmp_path):
11
+ runner = CliRunner()
12
+ config_path = tmp_path / "config.toml"
13
+ config_path.write_text(
14
+ '[gateway]\nurl = "http://localhost:9000"\napi_key = "sk-proxy"\n'
15
+ )
16
+ with (
17
+ patch("luthien_cli.commands.claude.DEFAULT_CONFIG_PATH", config_path),
18
+ patch("luthien_cli.commands.claude.shutil.which", return_value="/usr/bin/claude"),
19
+ patch("os.execvpe") as mock_exec,
20
+ ):
21
+ result = runner.invoke(cli, ["claude", "--", "--model", "opus"])
22
+ mock_exec.assert_called_once()
23
+ call_args = mock_exec.call_args
24
+ assert call_args[0][0] == "claude"
25
+ env = call_args[0][2]
26
+ assert env["ANTHROPIC_BASE_URL"] == "http://localhost:9000/"
27
+ assert env["ANTHROPIC_API_KEY"] == "sk-proxy"
28
+
29
+
30
+ def test_claude_fails_when_not_installed(tmp_path):
31
+ runner = CliRunner()
32
+ config_path = tmp_path / "config.toml"
33
+ config_path.write_text(
34
+ '[gateway]\nurl = "http://localhost:8000"\napi_key = "sk-test"\n'
35
+ )
36
+ with (
37
+ patch("luthien_cli.commands.claude.DEFAULT_CONFIG_PATH", config_path),
38
+ patch("luthien_cli.commands.claude.shutil.which", return_value=None),
39
+ ):
40
+ result = runner.invoke(cli, ["claude"])
41
+ assert result.exit_code != 0
42
+ assert (
43
+ "not found" in result.output.lower()
44
+ or "not installed" in result.output.lower()
45
+ )
46
+
47
+
48
+ def test_claude_fails_without_api_key(tmp_path):
49
+ runner = CliRunner()
50
+ config_path = tmp_path / "config.toml"
51
+ config_path.write_text('[gateway]\nurl = "http://localhost:8000"\n')
52
+ with patch("luthien_cli.commands.claude.DEFAULT_CONFIG_PATH", config_path):
53
+ result = runner.invoke(cli, ["claude"])
54
+ assert result.exit_code != 0
55
+ assert "api key" in result.output.lower() or "api_key" in result.output.lower()
@@ -0,0 +1,53 @@
1
+ """Tests for config module."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from luthien_cli.config import LuthienConfig, load_config, save_config
8
+
9
+
10
+ @pytest.fixture
11
+ def config_dir(tmp_path):
12
+ """Use a temp dir for config instead of ~/.luthien/."""
13
+ config_path = tmp_path / "config.toml"
14
+ return tmp_path, config_path
15
+
16
+
17
+ def test_load_config_returns_defaults_when_no_file(config_dir):
18
+ _, config_path = config_dir
19
+ config = load_config(config_path)
20
+ assert config.gateway_url == "http://localhost:8000"
21
+ assert config.api_key is None
22
+ assert config.admin_key is None
23
+ assert config.repo_path is None
24
+
25
+
26
+ def test_save_and_load_roundtrip(config_dir):
27
+ _, config_path = config_dir
28
+ config = LuthienConfig(
29
+ gateway_url="http://remote:9000",
30
+ api_key="sk-test",
31
+ admin_key="admin-test",
32
+ repo_path="/home/user/luthien-proxy",
33
+ )
34
+ save_config(config, config_path)
35
+ loaded = load_config(config_path)
36
+ assert loaded.gateway_url == "http://remote:9000"
37
+ assert loaded.api_key == "sk-test"
38
+ assert loaded.admin_key == "admin-test"
39
+ assert loaded.repo_path == "/home/user/luthien-proxy"
40
+
41
+
42
+ def test_save_creates_parent_directory(tmp_path):
43
+ config_path = tmp_path / "subdir" / "config.toml"
44
+ config = LuthienConfig()
45
+ save_config(config, config_path)
46
+ assert config_path.exists()
47
+
48
+
49
+ def test_load_config_ignores_unknown_keys(config_dir):
50
+ _, config_path = config_dir
51
+ config_path.write_text('[gateway]\nurl = "http://x"\nfoo = "bar"\n')
52
+ config = load_config(config_path)
53
+ assert config.gateway_url == "http://x"
@@ -0,0 +1,56 @@
1
+ """Tests for config command."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from luthien_cli.main import cli
8
+
9
+
10
+ def test_config_show_displays_config(tmp_path):
11
+ config_path = tmp_path / "config.toml"
12
+ config_path.write_text(
13
+ '[gateway]\nurl = "http://localhost:9000"\napi_key = "sk-test"\n'
14
+ )
15
+ runner = CliRunner()
16
+ with patch("luthien_cli.commands.config_cmd.DEFAULT_CONFIG_PATH", config_path):
17
+ result = runner.invoke(cli, ["config", "show"])
18
+ assert result.exit_code == 0
19
+ assert "localhost:9000" in result.output
20
+
21
+
22
+ def test_config_set_updates_value(tmp_path):
23
+ config_path = tmp_path / "config.toml"
24
+ config_path.write_text('[gateway]\nurl = "http://localhost:8000"\n')
25
+ runner = CliRunner()
26
+ with patch("luthien_cli.commands.config_cmd.DEFAULT_CONFIG_PATH", config_path):
27
+ result = runner.invoke(
28
+ cli, ["config", "set", "gateway.url", "http://remote:9000"]
29
+ )
30
+ assert result.exit_code == 0
31
+ with patch("luthien_cli.commands.config_cmd.DEFAULT_CONFIG_PATH", config_path):
32
+ result = runner.invoke(cli, ["config", "show"])
33
+ assert "remote:9000" in result.output
34
+
35
+
36
+ def test_config_set_rejects_unknown_key(tmp_path):
37
+ config_path = tmp_path / "config.toml"
38
+ config_path.write_text('[gateway]\nurl = "http://localhost:8000"\n')
39
+ runner = CliRunner()
40
+ with patch("luthien_cli.commands.config_cmd.DEFAULT_CONFIG_PATH", config_path):
41
+ result = runner.invoke(cli, ["config", "set", "bogus.key", "value"])
42
+ assert result.exit_code != 0
43
+ assert "unknown" in result.output.lower()
44
+
45
+
46
+ def test_config_show_masks_api_key(tmp_path):
47
+ config_path = tmp_path / "config.toml"
48
+ config_path.write_text(
49
+ '[gateway]\nurl = "http://localhost:8000"\napi_key = "sk-very-long-secret-key"\n'
50
+ )
51
+ runner = CliRunner()
52
+ with patch("luthien_cli.commands.config_cmd.DEFAULT_CONFIG_PATH", config_path):
53
+ result = runner.invoke(cli, ["config", "show"])
54
+ assert result.exit_code == 0
55
+ assert "sk-very-long-secret-key" not in result.output
56
+ assert "sk-v" in result.output
@@ -0,0 +1,55 @@
1
+ """Tests for gateway client."""
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from luthien_cli.gateway_client import GatewayClient, GatewayError
7
+
8
+
9
+ @pytest.fixture
10
+ def client():
11
+ return GatewayClient(
12
+ base_url="http://localhost:8000",
13
+ api_key="sk-test",
14
+ admin_key="admin-test",
15
+ )
16
+
17
+
18
+ def test_health_success(client, httpx_mock):
19
+ httpx_mock.add_response(
20
+ url="http://localhost:8000/health",
21
+ json={"status": "healthy", "version": "2.0.0"},
22
+ )
23
+ result = client.health()
24
+ assert result["status"] == "healthy"
25
+
26
+
27
+ def test_health_connection_error(client, httpx_mock):
28
+ httpx_mock.add_exception(httpx.ConnectError("refused"))
29
+ with pytest.raises(GatewayError, match="Cannot connect"):
30
+ client.health()
31
+
32
+
33
+ def test_get_current_policy(client, httpx_mock):
34
+ httpx_mock.add_response(
35
+ url="http://localhost:8000/api/admin/policy/current",
36
+ json={
37
+ "policy": "NoOpPolicy",
38
+ "class_ref": "luthien_proxy.policies.noop_policy:NoOpPolicy",
39
+ "enabled_at": "2026-03-03T10:00:00",
40
+ "enabled_by": "api",
41
+ "config": {},
42
+ },
43
+ )
44
+ result = client.get_current_policy()
45
+ assert result["policy"] == "NoOpPolicy"
46
+
47
+
48
+ def test_get_auth_config(client, httpx_mock):
49
+ httpx_mock.add_response(
50
+ url="http://localhost:8000/api/admin/auth/config",
51
+ json={"auth_mode": "both", "validate_credentials": True,
52
+ "valid_cache_ttl_seconds": 300, "invalid_cache_ttl_seconds": 60},
53
+ )
54
+ result = client.get_auth_config()
55
+ assert result["auth_mode"] == "both"
@@ -0,0 +1,27 @@
1
+ """Integration test — verify all commands are registered and --help works."""
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from luthien_cli.main import cli
6
+
7
+
8
+ def test_all_commands_registered():
9
+ runner = CliRunner()
10
+ result = runner.invoke(cli, ["--help"])
11
+ assert result.exit_code == 0
12
+ for cmd in ["status", "claude", "up", "down", "logs", "config"]:
13
+ assert cmd in result.output, f"Command '{cmd}' not in help output"
14
+
15
+
16
+ def test_each_command_has_help():
17
+ runner = CliRunner()
18
+ for cmd in ["status", "claude", "up", "down", "logs", "config"]:
19
+ result = runner.invoke(cli, [cmd, "--help"])
20
+ assert result.exit_code == 0, f"{cmd} --help failed"
21
+
22
+
23
+ def test_version():
24
+ runner = CliRunner()
25
+ result = runner.invoke(cli, ["--version"])
26
+ assert result.exit_code == 0
27
+ assert "0.1.0" in result.output
@@ -0,0 +1,70 @@
1
+ """Tests for logs command."""
2
+
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from luthien_cli.main import cli
8
+
9
+
10
+ def test_logs_runs_docker_compose_logs(tmp_path):
11
+ runner = CliRunner()
12
+ config_path = tmp_path / "config.toml"
13
+ config_path.write_text(
14
+ f'[gateway]\nurl = "http://localhost:8000"\n\n[local]\nrepo_path = "{tmp_path}"\n'
15
+ )
16
+ with (
17
+ patch("luthien_cli.commands.logs.DEFAULT_CONFIG_PATH", config_path),
18
+ patch("luthien_cli.commands.logs.subprocess.run") as mock_run,
19
+ ):
20
+ mock_run.return_value = MagicMock(returncode=0)
21
+ result = runner.invoke(cli, ["logs"])
22
+ assert result.exit_code == 0
23
+ args = mock_run.call_args[0][0]
24
+ assert "docker" in args
25
+ assert "logs" in args
26
+
27
+
28
+ def test_logs_with_tail(tmp_path):
29
+ runner = CliRunner()
30
+ config_path = tmp_path / "config.toml"
31
+ config_path.write_text(
32
+ f'[gateway]\nurl = "http://localhost:8000"\n\n[local]\nrepo_path = "{tmp_path}"\n'
33
+ )
34
+ with (
35
+ patch("luthien_cli.commands.logs.DEFAULT_CONFIG_PATH", config_path),
36
+ patch("luthien_cli.commands.logs.subprocess.run") as mock_run,
37
+ ):
38
+ mock_run.return_value = MagicMock(returncode=0)
39
+ result = runner.invoke(cli, ["logs", "--tail", "50"])
40
+ assert result.exit_code == 0
41
+ args = mock_run.call_args[0][0]
42
+ assert "--tail" in args
43
+ assert "50" in args
44
+
45
+
46
+ def test_logs_with_follow(tmp_path):
47
+ runner = CliRunner()
48
+ config_path = tmp_path / "config.toml"
49
+ config_path.write_text(
50
+ f'[gateway]\nurl = "http://localhost:8000"\n\n[local]\nrepo_path = "{tmp_path}"\n'
51
+ )
52
+ with (
53
+ patch("luthien_cli.commands.logs.DEFAULT_CONFIG_PATH", config_path),
54
+ patch("luthien_cli.commands.logs.subprocess.run") as mock_run,
55
+ ):
56
+ mock_run.return_value = MagicMock(returncode=0)
57
+ result = runner.invoke(cli, ["logs", "-f"])
58
+ assert result.exit_code == 0
59
+ args = mock_run.call_args[0][0]
60
+ assert "-f" in args
61
+
62
+
63
+ def test_logs_fails_without_repo_path(tmp_path):
64
+ runner = CliRunner()
65
+ config_path = tmp_path / "config.toml"
66
+ config_path.write_text('[gateway]\nurl = "http://localhost:8000"\n')
67
+ with patch("luthien_cli.commands.logs.DEFAULT_CONFIG_PATH", config_path):
68
+ result = runner.invoke(cli, ["logs"])
69
+ assert result.exit_code != 0
70
+ assert "repo_path" in result.output.lower() or "no repo" in result.output.lower()
@@ -0,0 +1,43 @@
1
+ """Tests for status command."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from luthien_cli.main import cli
8
+
9
+
10
+ def test_status_shows_healthy_gateway():
11
+ runner = CliRunner()
12
+ with patch("luthien_cli.commands.status.make_client") as mock_client:
13
+ client = mock_client.return_value
14
+ client.base_url = "http://localhost:8000"
15
+ client.health.return_value = {"status": "healthy", "version": "2.0.0"}
16
+ client.get_current_policy.return_value = {
17
+ "policy": "NoOpPolicy",
18
+ "class_ref": "luthien_proxy.policies.noop_policy:NoOpPolicy",
19
+ "enabled_at": "2026-03-03T10:00:00",
20
+ "enabled_by": "api",
21
+ "config": {},
22
+ }
23
+ client.get_auth_config.return_value = {
24
+ "auth_mode": "both",
25
+ "validate_credentials": True,
26
+ "valid_cache_ttl_seconds": 300,
27
+ "invalid_cache_ttl_seconds": 60,
28
+ }
29
+ result = runner.invoke(cli, ["status"])
30
+ assert result.exit_code == 0
31
+ assert "healthy" in result.output
32
+ assert "NoOpPolicy" in result.output
33
+
34
+
35
+ def test_status_shows_unreachable_gateway():
36
+ runner = CliRunner()
37
+ with patch("luthien_cli.commands.status.make_client") as mock_client:
38
+ from luthien_cli.gateway_client import GatewayError
39
+
40
+ client = mock_client.return_value
41
+ client.health.side_effect = GatewayError("Cannot connect")
42
+ result = runner.invoke(cli, ["status"])
43
+ assert result.exit_code != 0 or "Cannot connect" in result.output
@@ -0,0 +1,58 @@
1
+ """Tests for up/down commands."""
2
+
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from luthien_cli.main import cli
8
+
9
+
10
+ def test_up_runs_docker_compose(tmp_path):
11
+ runner = CliRunner()
12
+ config_path = tmp_path / "config.toml"
13
+ config_path.write_text(
14
+ f'[gateway]\nurl = "http://localhost:8000"\n\n[local]\nrepo_path = "{tmp_path}"\n'
15
+ )
16
+ with (
17
+ patch("luthien_cli.commands.up.DEFAULT_CONFIG_PATH", config_path),
18
+ patch("luthien_cli.commands.up.subprocess.run") as mock_run,
19
+ patch("luthien_cli.commands.up.wait_for_healthy", return_value=True),
20
+ ):
21
+ mock_run.return_value = MagicMock(returncode=0)
22
+ result = runner.invoke(cli, ["up"])
23
+ assert result.exit_code == 0
24
+ mock_run.assert_called()
25
+
26
+
27
+ def test_up_prompts_for_repo_path_when_missing(tmp_path):
28
+ runner = CliRunner()
29
+ config_path = tmp_path / "config.toml"
30
+ config_path.write_text('[gateway]\nurl = "http://localhost:8000"\n')
31
+ with patch("luthien_cli.commands.up.DEFAULT_CONFIG_PATH", config_path):
32
+ result = runner.invoke(cli, ["up"], input="\n")
33
+ assert "repo" in result.output.lower()
34
+
35
+
36
+ def test_down_runs_docker_compose_down(tmp_path):
37
+ runner = CliRunner()
38
+ config_path = tmp_path / "config.toml"
39
+ config_path.write_text(
40
+ f'[gateway]\nurl = "http://localhost:8000"\n\n[local]\nrepo_path = "{tmp_path}"\n'
41
+ )
42
+ with (
43
+ patch("luthien_cli.commands.up.DEFAULT_CONFIG_PATH", config_path),
44
+ patch("luthien_cli.commands.up.subprocess.run") as mock_run,
45
+ ):
46
+ mock_run.return_value = MagicMock(returncode=0)
47
+ result = runner.invoke(cli, ["down"])
48
+ assert result.exit_code == 0
49
+
50
+
51
+ def test_down_fails_without_repo_path(tmp_path):
52
+ runner = CliRunner()
53
+ config_path = tmp_path / "config.toml"
54
+ config_path.write_text('[gateway]\nurl = "http://localhost:8000"\n')
55
+ with patch("luthien_cli.commands.up.DEFAULT_CONFIG_PATH", config_path):
56
+ result = runner.invoke(cli, ["down"])
57
+ assert result.exit_code != 0
58
+ assert "repo_path" in result.output.lower() or "no repo" in result.output.lower()