aegis-gateway-cli 2.0.0a0__py3-none-any.whl
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.
- aegis_cli/__init__.py +3 -0
- aegis_cli/commands/__init__.py +1 -0
- aegis_cli/commands/chat.py +65 -0
- aegis_cli/commands/config.py +77 -0
- aegis_cli/commands/doctor.py +246 -0
- aegis_cli/commands/init.py +122 -0
- aegis_cli/commands/keys.py +87 -0
- aegis_cli/commands/plugin.py +131 -0
- aegis_cli/commands/policy.py +293 -0
- aegis_cli/commands/provider.py +171 -0
- aegis_cli/commands/rag.py +124 -0
- aegis_cli/commands/runs.py +106 -0
- aegis_cli/commands/scaffold.py +331 -0
- aegis_cli/commands/serve.py +66 -0
- aegis_cli/main.py +62 -0
- aegis_gateway_cli-2.0.0a0.dist-info/METADATA +14 -0
- aegis_gateway_cli-2.0.0a0.dist-info/RECORD +19 -0
- aegis_gateway_cli-2.0.0a0.dist-info/WHEEL +4 -0
- aegis_gateway_cli-2.0.0a0.dist-info/entry_points.txt +2 -0
aegis_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Aegis CLI command modules."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CLI command: `aegis chat`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from aegis_core.pipeline import PipelineAssembler, RunState
|
|
14
|
+
from aegis_core.providers.models import Message
|
|
15
|
+
from aegis_core.testing import FakeProvider
|
|
16
|
+
|
|
17
|
+
_console = Console()
|
|
18
|
+
_err_console = Console(stderr=True, style="bold red")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def chat(
|
|
22
|
+
message: Annotated[str, typer.Argument(help="The message to send.")],
|
|
23
|
+
route: Annotated[
|
|
24
|
+
str,
|
|
25
|
+
typer.Option("--route", "-r", help="Route name to use."),
|
|
26
|
+
] = "default",
|
|
27
|
+
json_output: Annotated[
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option("--json", help="Emit JSON with the full event log."),
|
|
30
|
+
] = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Send *message* through the pipeline and print the response."""
|
|
33
|
+
provider = FakeProvider(
|
|
34
|
+
name="fake",
|
|
35
|
+
complete_response=f"[fake] echo: {message}",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assembler = PipelineAssembler()
|
|
39
|
+
pipeline = assembler.compile(provider=provider, route=route)
|
|
40
|
+
|
|
41
|
+
initial_state = RunState(
|
|
42
|
+
run_id=str(uuid.uuid4()),
|
|
43
|
+
route=route,
|
|
44
|
+
messages=[Message(role="user", content=message)],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
result = asyncio.run(pipeline.run(initial_state))
|
|
48
|
+
|
|
49
|
+
if json_output:
|
|
50
|
+
output = {
|
|
51
|
+
"run_id": result.run_id,
|
|
52
|
+
"route": result.route,
|
|
53
|
+
"status": result.status,
|
|
54
|
+
"response": result.response,
|
|
55
|
+
"events": [e.to_dict() for e in result.events],
|
|
56
|
+
"usage": {
|
|
57
|
+
"prompt_tokens": result.usage.prompt_tokens,
|
|
58
|
+
"completion_tokens": result.usage.completion_tokens,
|
|
59
|
+
"total_tokens": result.usage.total_tokens,
|
|
60
|
+
"cost": result.usage.cost,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
_console.print(json.dumps(output, indent=2), markup=False)
|
|
64
|
+
else:
|
|
65
|
+
_console.print(result.response or "", markup=False)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""CLI commands: `aegis config validate` and `aegis config show`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
|
|
13
|
+
from aegis_core.config import load_config
|
|
14
|
+
from aegis_core.errors import AegisConfigError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="config", help="Validate and inspect Aegis configuration.")
|
|
17
|
+
_console = Console()
|
|
18
|
+
_err_console = Console(stderr=True, style="bold red")
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CONFIG = Path("aegis.yaml")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("validate")
|
|
24
|
+
def validate(
|
|
25
|
+
config_path: Annotated[
|
|
26
|
+
Path,
|
|
27
|
+
typer.Argument(help="Path to aegis.yaml to validate."),
|
|
28
|
+
] = _DEFAULT_CONFIG,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Validate an aegis.yaml file and report any errors."""
|
|
31
|
+
try:
|
|
32
|
+
load_config(config_path)
|
|
33
|
+
except AegisConfigError as exc:
|
|
34
|
+
_err_console.print(str(exc))
|
|
35
|
+
raise typer.Exit(1) from exc
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
_err_console.print(f"Unexpected error: {exc}")
|
|
38
|
+
raise typer.Exit(1) from exc
|
|
39
|
+
|
|
40
|
+
_console.print(f"[green]✓[/green] {config_path} is valid.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("show")
|
|
44
|
+
def show(
|
|
45
|
+
config_path: Annotated[
|
|
46
|
+
Path,
|
|
47
|
+
typer.Argument(help="Path to aegis.yaml to display."),
|
|
48
|
+
] = _DEFAULT_CONFIG,
|
|
49
|
+
output_format: Annotated[
|
|
50
|
+
str,
|
|
51
|
+
typer.Option("--format", "-f", help="Output format: json or yaml."),
|
|
52
|
+
] = "json",
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Load and display the resolved configuration (secrets redacted)."""
|
|
55
|
+
try:
|
|
56
|
+
cfg = load_config(config_path)
|
|
57
|
+
except AegisConfigError as exc:
|
|
58
|
+
_err_console.print(str(exc))
|
|
59
|
+
raise typer.Exit(1) from exc
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
_err_console.print(f"Unexpected error: {exc}")
|
|
62
|
+
raise typer.Exit(1) from exc
|
|
63
|
+
|
|
64
|
+
safe = cfg.safe_dict()
|
|
65
|
+
|
|
66
|
+
if output_format == "yaml":
|
|
67
|
+
try:
|
|
68
|
+
import yaml # type: ignore[import-untyped]
|
|
69
|
+
|
|
70
|
+
text = yaml.dump(safe, default_flow_style=False, sort_keys=False)
|
|
71
|
+
_console.print(Syntax(text, "yaml", theme="monokai"))
|
|
72
|
+
except ImportError:
|
|
73
|
+
_err_console.print("PyYAML is required for YAML output.")
|
|
74
|
+
raise typer.Exit(1) from None
|
|
75
|
+
else:
|
|
76
|
+
text = json.dumps(safe, indent=2, default=str)
|
|
77
|
+
_console.print(Syntax(text, "json", theme="monokai"))
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""CLI command: `aegis doctor` — environment health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="doctor", invoke_without_command=True, help="Check Aegis environment health.")
|
|
16
|
+
_console = Console()
|
|
17
|
+
_err_console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
_DEFAULT_CONFIG = Path("aegis.yaml")
|
|
20
|
+
_DEFAULT_PROVIDERS_STORE = Path.home() / ".aegis" / "providers.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CheckStatus(StrEnum):
|
|
24
|
+
OK = "ok"
|
|
25
|
+
WARN = "warn"
|
|
26
|
+
FAIL = "fail"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HealthCheck:
|
|
31
|
+
name: str
|
|
32
|
+
status: CheckStatus
|
|
33
|
+
detail: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Individual checks
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_config(config_path: Path) -> HealthCheck:
|
|
42
|
+
"""AEG-CFG: aegis.yaml exists and is valid YAML."""
|
|
43
|
+
if not config_path.exists():
|
|
44
|
+
return HealthCheck(
|
|
45
|
+
name="config",
|
|
46
|
+
status=CheckStatus.FAIL,
|
|
47
|
+
detail=f"{config_path} not found. Run `aegis init` to create one.",
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
import yaml
|
|
51
|
+
|
|
52
|
+
with open(config_path) as f:
|
|
53
|
+
raw = yaml.safe_load(f)
|
|
54
|
+
if not isinstance(raw, (dict, type(None))):
|
|
55
|
+
return HealthCheck(
|
|
56
|
+
name="config",
|
|
57
|
+
status=CheckStatus.FAIL,
|
|
58
|
+
detail=f"{config_path} does not contain a YAML mapping.",
|
|
59
|
+
)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return HealthCheck(
|
|
62
|
+
name="config",
|
|
63
|
+
status=CheckStatus.FAIL,
|
|
64
|
+
detail=f"{config_path} could not be parsed: {exc}",
|
|
65
|
+
)
|
|
66
|
+
return HealthCheck(name="config", status=CheckStatus.OK, detail=str(config_path))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_pii_extra() -> HealthCheck:
|
|
70
|
+
"""AEG-POL: presidio-analyzer (PII extra) is installed."""
|
|
71
|
+
spec = None
|
|
72
|
+
try:
|
|
73
|
+
spec = importlib.util.find_spec("presidio_analyzer")
|
|
74
|
+
except ModuleNotFoundError:
|
|
75
|
+
pass
|
|
76
|
+
if spec is None:
|
|
77
|
+
return HealthCheck(
|
|
78
|
+
name="pii_extra",
|
|
79
|
+
status=CheckStatus.WARN,
|
|
80
|
+
detail=(
|
|
81
|
+
"presidio-analyzer not installed. "
|
|
82
|
+
"Install aegis-pack-pii[pii] to enable PII masking."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
return HealthCheck(name="pii_extra", status=CheckStatus.OK, detail="presidio-analyzer found.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_rag_extra() -> HealthCheck:
|
|
89
|
+
"""AEG-RAG: chromadb (RAG extra) is installed."""
|
|
90
|
+
spec = None
|
|
91
|
+
try:
|
|
92
|
+
spec = importlib.util.find_spec("chromadb")
|
|
93
|
+
except ModuleNotFoundError:
|
|
94
|
+
pass
|
|
95
|
+
if spec is None:
|
|
96
|
+
return HealthCheck(
|
|
97
|
+
name="rag_extra",
|
|
98
|
+
status=CheckStatus.WARN,
|
|
99
|
+
detail=(
|
|
100
|
+
"chromadb not installed. "
|
|
101
|
+
"Install aegis-core[rag] to enable RAG retrieval."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
return HealthCheck(name="rag_extra", status=CheckStatus.OK, detail="chromadb found.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_provider_store(store_path: Path = _DEFAULT_PROVIDERS_STORE) -> HealthCheck:
|
|
108
|
+
"""AEG-PRV: provider profile store file exists."""
|
|
109
|
+
if not store_path.exists():
|
|
110
|
+
return HealthCheck(
|
|
111
|
+
name="provider_store",
|
|
112
|
+
status=CheckStatus.WARN,
|
|
113
|
+
detail=(
|
|
114
|
+
f"{store_path} not found. "
|
|
115
|
+
"Run `aegis provider add` to create a provider profile."
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
return HealthCheck(
|
|
119
|
+
name="provider_store",
|
|
120
|
+
status=CheckStatus.OK,
|
|
121
|
+
detail=str(store_path),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_providers_reachable(store_path: Path = _DEFAULT_PROVIDERS_STORE) -> HealthCheck:
|
|
126
|
+
"""AEG-PRV: ping each provider in the store (opt-in)."""
|
|
127
|
+
if not store_path.exists():
|
|
128
|
+
return HealthCheck(
|
|
129
|
+
name="providers_reachable",
|
|
130
|
+
status=CheckStatus.WARN,
|
|
131
|
+
detail="No provider store found; skipping reachability check.",
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
with open(store_path) as f:
|
|
137
|
+
data = json.load(f)
|
|
138
|
+
profiles = data if isinstance(data, list) else []
|
|
139
|
+
if not profiles:
|
|
140
|
+
return HealthCheck(
|
|
141
|
+
name="providers_reachable",
|
|
142
|
+
status=CheckStatus.WARN,
|
|
143
|
+
detail="No provider profiles configured.",
|
|
144
|
+
)
|
|
145
|
+
# Best-effort TCP reachability for each profile with a base_url
|
|
146
|
+
import socket
|
|
147
|
+
import urllib.parse
|
|
148
|
+
|
|
149
|
+
unreachable: list[str] = []
|
|
150
|
+
for profile in profiles:
|
|
151
|
+
base_url = profile.get("base_url")
|
|
152
|
+
if not base_url:
|
|
153
|
+
continue
|
|
154
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
155
|
+
host = parsed.hostname or ""
|
|
156
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
157
|
+
try:
|
|
158
|
+
with socket.create_connection((host, port), timeout=3):
|
|
159
|
+
pass
|
|
160
|
+
except OSError:
|
|
161
|
+
unreachable.append(profile.get("name", host))
|
|
162
|
+
|
|
163
|
+
if unreachable:
|
|
164
|
+
return HealthCheck(
|
|
165
|
+
name="providers_reachable",
|
|
166
|
+
status=CheckStatus.FAIL,
|
|
167
|
+
detail=f"Unreachable: {', '.join(unreachable)}",
|
|
168
|
+
)
|
|
169
|
+
return HealthCheck(
|
|
170
|
+
name="providers_reachable",
|
|
171
|
+
status=CheckStatus.OK,
|
|
172
|
+
detail=f"All {len(profiles)} provider(s) reachable.",
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
return HealthCheck(
|
|
176
|
+
name="providers_reachable",
|
|
177
|
+
status=CheckStatus.FAIL,
|
|
178
|
+
detail=f"Check failed: {exc}",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Public helper for tests
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_checks(
|
|
188
|
+
config_path: Path = _DEFAULT_CONFIG,
|
|
189
|
+
store_path: Path = _DEFAULT_PROVIDERS_STORE,
|
|
190
|
+
check_providers: bool = False,
|
|
191
|
+
) -> list[HealthCheck]:
|
|
192
|
+
"""Run all health checks and return the results."""
|
|
193
|
+
checks: list[HealthCheck] = [
|
|
194
|
+
check_config(config_path),
|
|
195
|
+
check_pii_extra(),
|
|
196
|
+
check_rag_extra(),
|
|
197
|
+
check_provider_store(store_path),
|
|
198
|
+
]
|
|
199
|
+
if check_providers:
|
|
200
|
+
checks.append(check_providers_reachable(store_path))
|
|
201
|
+
return checks
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Command
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.callback(invoke_without_command=True)
|
|
210
|
+
def doctor(
|
|
211
|
+
config_path: Annotated[
|
|
212
|
+
Path,
|
|
213
|
+
typer.Option("--config", "-c", help="Path to aegis.yaml to validate."),
|
|
214
|
+
] = _DEFAULT_CONFIG,
|
|
215
|
+
store_path: Annotated[
|
|
216
|
+
Path,
|
|
217
|
+
typer.Option("--store", help="Path to provider profile store."),
|
|
218
|
+
] = _DEFAULT_PROVIDERS_STORE,
|
|
219
|
+
check_providers: Annotated[
|
|
220
|
+
bool,
|
|
221
|
+
typer.Option("--check-providers", help="Ping each configured provider (opt-in)."),
|
|
222
|
+
] = False,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Check Aegis environment health (config, extras, provider store)."""
|
|
225
|
+
checks = run_checks(config_path, store_path, check_providers)
|
|
226
|
+
|
|
227
|
+
table = Table(title="Aegis Doctor", show_header=True, header_style="bold cyan")
|
|
228
|
+
table.add_column("Check", style="bold")
|
|
229
|
+
table.add_column("Status")
|
|
230
|
+
table.add_column("Detail")
|
|
231
|
+
|
|
232
|
+
any_fail = False
|
|
233
|
+
for check in checks:
|
|
234
|
+
if check.status == CheckStatus.OK:
|
|
235
|
+
status_str = "[green]OK[/green]"
|
|
236
|
+
elif check.status == CheckStatus.WARN:
|
|
237
|
+
status_str = "[yellow]WARN[/yellow]"
|
|
238
|
+
else:
|
|
239
|
+
status_str = "[red]FAIL[/red]"
|
|
240
|
+
any_fail = True
|
|
241
|
+
table.add_row(check.name, status_str, check.detail)
|
|
242
|
+
|
|
243
|
+
_console.print(table)
|
|
244
|
+
|
|
245
|
+
if any_fail:
|
|
246
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""CLI command: `aegis init` — generate a starter aegis.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(name="init", invoke_without_command=True, help="Generate a starter aegis.yaml.")
|
|
12
|
+
_console = Console()
|
|
13
|
+
_err_console = Console(stderr=True, style="bold red")
|
|
14
|
+
|
|
15
|
+
_DEFAULT_OUTPUT = Path("aegis.yaml")
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Template
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_TEMPLATE = """\
|
|
22
|
+
# aegis.yaml — generated by `aegis init`
|
|
23
|
+
# Run `aegis config validate aegis.yaml` to validate this file.
|
|
24
|
+
# Run `aegis policy lint aegis.yaml` to check policy references.
|
|
25
|
+
|
|
26
|
+
# ── Providers ─────────────────────────────────────────────────────────────
|
|
27
|
+
# Uncomment and configure at least one provider before serving requests.
|
|
28
|
+
#
|
|
29
|
+
# providers:
|
|
30
|
+
# my_provider:
|
|
31
|
+
# type: openai_compatible
|
|
32
|
+
# base_url: http://localhost:11434/v1
|
|
33
|
+
# model: llama3
|
|
34
|
+
# api_key: secret://env/MY_API_KEY#value
|
|
35
|
+
|
|
36
|
+
# ── Guardrails ────────────────────────────────────────────────────────────
|
|
37
|
+
# The PII guard is enabled by default (requires aegis-pack-pii[pii]).
|
|
38
|
+
# Add more guards from available packs below.
|
|
39
|
+
|
|
40
|
+
guardrails:
|
|
41
|
+
pii:
|
|
42
|
+
pack: aegis_pack_pii
|
|
43
|
+
mode: mask
|
|
44
|
+
|
|
45
|
+
# llm_guard:
|
|
46
|
+
# pack: aegis_pack_llm_guard
|
|
47
|
+
# scanners: [PromptInjection]
|
|
48
|
+
# threshold: 0.8
|
|
49
|
+
|
|
50
|
+
# residency:
|
|
51
|
+
# pack: aegis_pack_residency
|
|
52
|
+
# region: us
|
|
53
|
+
|
|
54
|
+
# ── Pipeline ──────────────────────────────────────────────────────────────
|
|
55
|
+
# Guardrail names listed here must match keys in the guardrails section above.
|
|
56
|
+
|
|
57
|
+
pipeline:
|
|
58
|
+
ingress: [pii]
|
|
59
|
+
# tool_result: [pii]
|
|
60
|
+
# egress: [] # note: non-incremental egress guards disable true-streaming
|
|
61
|
+
|
|
62
|
+
# ── Routes ────────────────────────────────────────────────────────────────
|
|
63
|
+
# Uncomment and configure after adding a provider above.
|
|
64
|
+
#
|
|
65
|
+
# routes:
|
|
66
|
+
# default:
|
|
67
|
+
# provider: my_provider
|
|
68
|
+
# model: llama3
|
|
69
|
+
|
|
70
|
+
# ── Auth ──────────────────────────────────────────────────────────────────
|
|
71
|
+
# Change to `type: api_key` and use `aegis keys create` for production.
|
|
72
|
+
|
|
73
|
+
auth:
|
|
74
|
+
type: none
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Public helper for tests
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_init_yaml(output: Path, force: bool = False) -> None:
|
|
84
|
+
"""Write the starter aegis.yaml to *output*.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FileExistsError: If *output* exists and *force* is False.
|
|
88
|
+
"""
|
|
89
|
+
if output.exists() and not force:
|
|
90
|
+
raise FileExistsError(
|
|
91
|
+
f"{output} already exists. Use --force to overwrite."
|
|
92
|
+
)
|
|
93
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
output.write_text(_TEMPLATE)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Command
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.callback(invoke_without_command=True)
|
|
103
|
+
def init(
|
|
104
|
+
output: Annotated[
|
|
105
|
+
Path,
|
|
106
|
+
typer.Option("--output", "-o", help="Output path for aegis.yaml."),
|
|
107
|
+
] = _DEFAULT_OUTPUT,
|
|
108
|
+
force: Annotated[
|
|
109
|
+
bool,
|
|
110
|
+
typer.Option("--force", "-f", help="Overwrite existing file."),
|
|
111
|
+
] = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Generate a starter aegis.yaml with PII guarding enabled."""
|
|
114
|
+
try:
|
|
115
|
+
write_init_yaml(output, force=force)
|
|
116
|
+
except FileExistsError as exc:
|
|
117
|
+
_err_console.print(str(exc))
|
|
118
|
+
raise typer.Exit(1) from exc
|
|
119
|
+
|
|
120
|
+
_console.print(f"[green]Created[/green] {output}")
|
|
121
|
+
_console.print(" Validate: [cyan]aegis config validate aegis.yaml[/cyan]")
|
|
122
|
+
_console.print(" Lint: [cyan]aegis policy lint aegis.yaml[/cyan]")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CLI commands: `aegis keys create|list|revoke` (PROJECT_SPEC D17)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage Aegis virtual API keys.", no_args_is_help=True)
|
|
13
|
+
_console = Console()
|
|
14
|
+
_DEFAULT_KEYS_PATH = Path.home() / ".aegis" / "keys.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_store(keys_path: Path) -> object:
|
|
18
|
+
from aegis_server.keys import KeyStore
|
|
19
|
+
|
|
20
|
+
return KeyStore(path=keys_path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("create")
|
|
24
|
+
def create(
|
|
25
|
+
principal_id: Annotated[str, typer.Argument(help="Principal ID for this key.")],
|
|
26
|
+
team: Annotated[str, typer.Option("--team", "-t", help="Team name.")] = "",
|
|
27
|
+
keys_path: Annotated[
|
|
28
|
+
Path,
|
|
29
|
+
typer.Option("--keys-file", help="Path to keys JSON file."),
|
|
30
|
+
] = _DEFAULT_KEYS_PATH,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Generate a new API key and print it once."""
|
|
33
|
+
from aegis_server.keys import KeyStore
|
|
34
|
+
|
|
35
|
+
store = KeyStore(path=keys_path)
|
|
36
|
+
key = store.create(principal_id=principal_id, team=team)
|
|
37
|
+
_console.print("[bold green]Key created.[/bold green] Store this — it will not be shown again.\n")
|
|
38
|
+
_console.print(key, markup=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("list")
|
|
42
|
+
def list_keys(
|
|
43
|
+
keys_path: Annotated[
|
|
44
|
+
Path,
|
|
45
|
+
typer.Option("--keys-file", help="Path to keys JSON file."),
|
|
46
|
+
] = _DEFAULT_KEYS_PATH,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""List all API keys (no plaintext shown)."""
|
|
49
|
+
from aegis_server.keys import KeyStore
|
|
50
|
+
|
|
51
|
+
store = KeyStore(path=keys_path)
|
|
52
|
+
entries = store.list()
|
|
53
|
+
if not entries:
|
|
54
|
+
_console.print("No keys found.")
|
|
55
|
+
return
|
|
56
|
+
table = Table(title="API Keys")
|
|
57
|
+
table.add_column("key_id")
|
|
58
|
+
table.add_column("principal_id")
|
|
59
|
+
table.add_column("team")
|
|
60
|
+
table.add_column("created_at")
|
|
61
|
+
for entry in entries:
|
|
62
|
+
table.add_row(
|
|
63
|
+
str(entry["key_id"]),
|
|
64
|
+
str(entry["principal_id"]),
|
|
65
|
+
str(entry["team"]),
|
|
66
|
+
str(entry["created_at"]),
|
|
67
|
+
)
|
|
68
|
+
_console.print(table)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("revoke")
|
|
72
|
+
def revoke(
|
|
73
|
+
key_id: Annotated[str, typer.Argument(help="The key_id to revoke.")],
|
|
74
|
+
keys_path: Annotated[
|
|
75
|
+
Path,
|
|
76
|
+
typer.Option("--keys-file", help="Path to keys JSON file."),
|
|
77
|
+
] = _DEFAULT_KEYS_PATH,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Revoke an API key by its key_id."""
|
|
80
|
+
from aegis_server.keys import KeyStore
|
|
81
|
+
|
|
82
|
+
store = KeyStore(path=keys_path)
|
|
83
|
+
if store.revoke(key_id):
|
|
84
|
+
_console.print(f"[green]Revoked {key_id}.[/green]")
|
|
85
|
+
else:
|
|
86
|
+
_console.print(f"[red]Key '{key_id}' not found.[/red]")
|
|
87
|
+
raise typer.Exit(code=1)
|