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.
- luthien_cli-0.1.0/.gitignore +61 -0
- luthien_cli-0.1.0/PKG-INFO +13 -0
- luthien_cli-0.1.0/README.md +61 -0
- luthien_cli-0.1.0/pyproject.toml +31 -0
- luthien_cli-0.1.0/src/luthien_cli/__init__.py +0 -0
- luthien_cli-0.1.0/src/luthien_cli/commands/__init__.py +0 -0
- luthien_cli-0.1.0/src/luthien_cli/commands/claude.py +45 -0
- luthien_cli-0.1.0/src/luthien_cli/commands/config_cmd.py +65 -0
- luthien_cli-0.1.0/src/luthien_cli/commands/logs.py +33 -0
- luthien_cli-0.1.0/src/luthien_cli/commands/status.py +53 -0
- luthien_cli-0.1.0/src/luthien_cli/commands/up.py +89 -0
- luthien_cli-0.1.0/src/luthien_cli/config.py +60 -0
- luthien_cli-0.1.0/src/luthien_cli/gateway_client.py +51 -0
- luthien_cli-0.1.0/src/luthien_cli/main.py +27 -0
- luthien_cli-0.1.0/tests/__init__.py +0 -0
- luthien_cli-0.1.0/tests/test_claude.py +55 -0
- luthien_cli-0.1.0/tests/test_config.py +53 -0
- luthien_cli-0.1.0/tests/test_config_cmd.py +56 -0
- luthien_cli-0.1.0/tests/test_gateway_client.py +55 -0
- luthien_cli-0.1.0/tests/test_integration.py +27 -0
- luthien_cli-0.1.0/tests/test_logs.py +70 -0
- luthien_cli-0.1.0/tests/test_status.py +43 -0
- luthien_cli-0.1.0/tests/test_up.py +58 -0
|
@@ -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()
|