sovaria-cli 0.2.0__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.
Files changed (46) hide show
  1. apps/cli/README.md +147 -0
  2. apps/cli/__init__.py +0 -0
  3. apps/cli/src/__init__.py +3 -0
  4. apps/cli/src/commands/__init__.py +1 -0
  5. apps/cli/src/commands/admin.py +160 -0
  6. apps/cli/src/commands/api.py +48 -0
  7. apps/cli/src/commands/audit.py +181 -0
  8. apps/cli/src/commands/auth_product.py +187 -0
  9. apps/cli/src/commands/billing.py +513 -0
  10. apps/cli/src/commands/chrono.py +248 -0
  11. apps/cli/src/commands/context.py +99 -0
  12. apps/cli/src/commands/data.py +272 -0
  13. apps/cli/src/commands/db.py +528 -0
  14. apps/cli/src/commands/deploy.py +1099 -0
  15. apps/cli/src/commands/governance.py +491 -0
  16. apps/cli/src/commands/iam.py +436 -0
  17. apps/cli/src/commands/init_cmd.py +177 -0
  18. apps/cli/src/commands/keys.py +120 -0
  19. apps/cli/src/commands/login.py +842 -0
  20. apps/cli/src/commands/org.py +157 -0
  21. apps/cli/src/commands/policy.py +317 -0
  22. apps/cli/src/commands/project.py +130 -0
  23. apps/cli/src/commands/realtime.py +364 -0
  24. apps/cli/src/commands/scope.py +42 -0
  25. apps/cli/src/commands/shell.py +415 -0
  26. apps/cli/src/commands/sim.py +178 -0
  27. apps/cli/src/commands/storage.py +405 -0
  28. apps/cli/src/commands/vector.py +204 -0
  29. apps/cli/src/main.py +169 -0
  30. apps/cli/src/utils/__init__.py +1 -0
  31. apps/cli/src/utils/api_client.py +329 -0
  32. apps/cli/src/utils/auth_store.py +299 -0
  33. apps/cli/src/utils/config.py +185 -0
  34. apps/cli/src/utils/display.py +62 -0
  35. apps/cli/src/utils/migrations.py +69 -0
  36. apps/cli/src/utils/project_context.py +124 -0
  37. apps/cli/tests/test_auth_store_profiles.py +103 -0
  38. apps/cli/tests/test_cli_security.py +110 -0
  39. apps/cli/tests/test_cli_smoke_paths.py +279 -0
  40. apps/cli/tests/test_config_profiles.py +55 -0
  41. apps/cli/tests/test_main_startup.py +29 -0
  42. apps/cli/tests/test_project_and_commands.py +196 -0
  43. sovaria_cli-0.2.0.dist-info/METADATA +170 -0
  44. sovaria_cli-0.2.0.dist-info/RECORD +46 -0
  45. sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
  46. sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,415 @@
1
+ """Interactive Sovaria shell REPL (`sov shell`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cmd
6
+ import shlex
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
12
+ from apps.cli.src.utils.auth_store import load_tokens
13
+ from apps.cli.src.utils.display import print_banner, print_error, print_json, print_sql_result, print_success
14
+ from apps.cli.src.utils.project_context import project_status
15
+
16
+ app = typer.Typer(help="Interactive Sovaria terminal", invoke_without_command=True)
17
+
18
+ _SHELL_COMMANDS = (
19
+ "help",
20
+ "whoami",
21
+ "status",
22
+ "scope",
23
+ "sql",
24
+ "sim",
25
+ "keys",
26
+ "policy",
27
+ "vector",
28
+ "billing",
29
+ "fn",
30
+ "db",
31
+ "sessions",
32
+ "api",
33
+ "exit",
34
+ "quit",
35
+ )
36
+
37
+ _HELP_LINES = """
38
+ Commands:
39
+ help Show this help
40
+ whoami Current authenticated user
41
+ status Local project link + scope
42
+ scope Show resolved org/project/env
43
+ sql <query> Run SQL (Atlas-Rel)
44
+ sim discover | runs | run <id>
45
+ keys list List project API keys
46
+ policy list | check --action A --resource R
47
+ vector collections List vector collections
48
+ billing usage Current billing usage
49
+ fn list List Forge functions
50
+ fn logs <name> Recent function logs
51
+ db migrations list --local Local migration files
52
+ sessions List your login sessions
53
+ api GET /path Raw gateway call
54
+ exit | quit Leave the shell
55
+
56
+ Tips:
57
+ • Tab completes command names; ↑/↓ history when readline is available
58
+ • Scope auto-resolves from sovaria.config.json — run `sov init` first
59
+ • Multi-word SQL: sql "SELECT * FROM users LIMIT 5"
60
+ """
61
+
62
+
63
+ def _setup_readline() -> None:
64
+ """Enable tab completion and persistent history when readline exists."""
65
+ try:
66
+ import readline
67
+ except ImportError:
68
+ return
69
+
70
+ def _completer(text: str, state: int) -> str | None:
71
+ options = [cmd_name for cmd_name in _SHELL_COMMANDS if cmd_name.startswith(text)]
72
+ if state < len(options):
73
+ return options[state]
74
+ return None
75
+
76
+ readline.set_completer(_completer)
77
+ readline.parse_and_bind("tab: complete")
78
+ history_path = Path.home() / ".sovaria" / "shell_history"
79
+ history_path.parent.mkdir(parents=True, exist_ok=True)
80
+ try:
81
+ readline.read_history_file(str(history_path))
82
+ except OSError:
83
+ pass
84
+
85
+ import atexit
86
+
87
+ def _save_history() -> None:
88
+ try:
89
+ readline.write_history_file(str(history_path))
90
+ except OSError:
91
+ pass
92
+
93
+ atexit.register(_save_history)
94
+
95
+
96
+ class SovariaShell(cmd.Cmd):
97
+ intro = ""
98
+ prompt = "sov> "
99
+ doc_header = "Sovaria interactive commands"
100
+
101
+ def __init__(self, *, profile: str | None = None) -> None:
102
+ super().__init__()
103
+ self.profile = profile
104
+
105
+ def precmd(self, line: str) -> str:
106
+ return line.strip()
107
+
108
+ def do_help(self, arg: str) -> None:
109
+ if arg:
110
+ super().do_help(arg)
111
+ return
112
+ typer.echo(_HELP_LINES)
113
+
114
+ def do_exit(self, arg: str) -> bool:
115
+ typer.echo("Bye.")
116
+ return True
117
+
118
+ def do_quit(self, arg: str) -> bool:
119
+ return self.do_exit(arg)
120
+
121
+ def do_whoami(self, arg: str) -> None:
122
+ try:
123
+ result = call_api(method="GET", path="/api/v1/auth/me", profile=self.profile)
124
+ print_json(result)
125
+ except CLIRequestError as exc:
126
+ print_error(str(exc))
127
+
128
+ def do_status(self, arg: str) -> None:
129
+ print_json(project_status())
130
+
131
+ def do_scope(self, arg: str) -> None:
132
+ status = project_status()
133
+ scope = status.get("scope") if isinstance(status, dict) else {}
134
+ if isinstance(scope, dict):
135
+ typer.echo(f"org_id : {scope.get('org_id') or '-'}")
136
+ typer.echo(f"project_id : {scope.get('project_id') or '-'}")
137
+ typer.echo(f"env_id : {scope.get('env_id') or '-'}")
138
+ else:
139
+ print_json(status)
140
+
141
+ def do_sql(self, arg: str) -> None:
142
+ if not arg:
143
+ typer.echo("Usage: sql SELECT ...")
144
+ return
145
+ try:
146
+ result = call_api(
147
+ method="POST",
148
+ path="/api/v1/sql/query",
149
+ profile=self.profile,
150
+ body={"sql": arg, "txid": 0},
151
+ require_scope=True,
152
+ )
153
+ print_sql_result(result)
154
+ except CLIRequestError as exc:
155
+ print_error(str(exc))
156
+
157
+ def do_sim(self, arg: str) -> None:
158
+ parts = shlex.split(arg)
159
+ if not parts:
160
+ typer.echo("Usage: sim discover | runs | run <scenario_id>")
161
+ return
162
+ sub = parts[0]
163
+ try:
164
+ if sub == "discover":
165
+ print_json(
166
+ call_api(
167
+ method="POST",
168
+ path="/api/v1/sim/discover",
169
+ profile=self.profile,
170
+ require_scope=True,
171
+ body={},
172
+ )
173
+ )
174
+ elif sub == "runs":
175
+ limit = "20"
176
+ if len(parts) >= 3 and parts[1] == "--limit":
177
+ limit = parts[2]
178
+ print_json(
179
+ call_api(
180
+ method="GET",
181
+ path="/api/v1/sim/runs",
182
+ profile=self.profile,
183
+ require_scope=True,
184
+ params={"limit": limit, "offset": "0"},
185
+ )
186
+ )
187
+ elif sub == "run" and len(parts) >= 2:
188
+ print_json(
189
+ call_api(
190
+ method="POST",
191
+ path="/api/v1/sim/run",
192
+ profile=self.profile,
193
+ require_scope=True,
194
+ body={"scenario_id": parts[1]},
195
+ )
196
+ )
197
+ else:
198
+ typer.echo("Usage: sim discover | runs [--limit N] | run <scenario_id>")
199
+ except CLIRequestError as exc:
200
+ print_error(str(exc))
201
+
202
+ def do_keys(self, arg: str) -> None:
203
+ parts = shlex.split(arg)
204
+ if parts and parts[0] != "list":
205
+ typer.echo("Usage: keys list")
206
+ return
207
+ try:
208
+ print_json(
209
+ call_api(
210
+ method="GET",
211
+ path="/api/v1/apikeys",
212
+ profile=self.profile,
213
+ require_scope=True,
214
+ )
215
+ )
216
+ except CLIRequestError as exc:
217
+ print_error(str(exc))
218
+
219
+ def do_policy(self, arg: str) -> None:
220
+ parts = shlex.split(arg)
221
+ if not parts:
222
+ typer.echo("Usage: policy list | policy check --action A --resource R")
223
+ return
224
+ try:
225
+ if parts[0] == "list":
226
+ print_json(
227
+ call_api(
228
+ method="GET",
229
+ path="/api/v1/policies/",
230
+ profile=self.profile,
231
+ require_scope=True,
232
+ )
233
+ )
234
+ elif parts[0] == "check":
235
+ action = ""
236
+ resource = ""
237
+ idx = 1
238
+ while idx < len(parts):
239
+ if parts[idx] == "--action" and idx + 1 < len(parts):
240
+ action = parts[idx + 1]
241
+ idx += 2
242
+ elif parts[idx] == "--resource" and idx + 1 < len(parts):
243
+ resource = parts[idx + 1]
244
+ idx += 2
245
+ else:
246
+ idx += 1
247
+ if not action or not resource:
248
+ typer.echo("Usage: policy check --action sql:select --resource users")
249
+ return
250
+ status = project_status()
251
+ scope = status.get("scope") if isinstance(status, dict) else {}
252
+ org_id = str((scope or {}).get("org_id") or "")
253
+ project_id = str((scope or {}).get("project_id") or "")
254
+ print_json(
255
+ call_api(
256
+ method="POST",
257
+ path="/api/v1/policies/check",
258
+ profile=self.profile,
259
+ body={
260
+ "org_id": org_id,
261
+ "project_id": project_id,
262
+ "action": action,
263
+ "resource": resource,
264
+ "context": {},
265
+ },
266
+ require_scope=True,
267
+ )
268
+ )
269
+ else:
270
+ typer.echo("Usage: policy list | policy check --action A --resource R")
271
+ except CLIRequestError as exc:
272
+ print_error(str(exc))
273
+
274
+ def do_vector(self, arg: str) -> None:
275
+ parts = shlex.split(arg)
276
+ if not parts or parts[0] != "collections":
277
+ typer.echo("Usage: vector collections")
278
+ return
279
+ try:
280
+ print_json(
281
+ call_api(
282
+ method="GET",
283
+ path="/api/v1/vector/collections",
284
+ profile=self.profile,
285
+ require_scope=True,
286
+ )
287
+ )
288
+ except CLIRequestError as exc:
289
+ print_error(str(exc))
290
+
291
+ def do_billing(self, arg: str) -> None:
292
+ parts = shlex.split(arg)
293
+ if not parts or parts[0] != "usage":
294
+ typer.echo("Usage: billing usage")
295
+ return
296
+ try:
297
+ print_json(
298
+ call_api(
299
+ method="GET",
300
+ path="/api/v1/billing/usage",
301
+ profile=self.profile,
302
+ require_scope=True,
303
+ )
304
+ )
305
+ except CLIRequestError as exc:
306
+ print_error(str(exc))
307
+
308
+ def do_fn(self, arg: str) -> None:
309
+ parts = shlex.split(arg)
310
+ if not parts:
311
+ typer.echo("Usage: fn list | fn logs <name>")
312
+ return
313
+ try:
314
+ if parts[0] == "list":
315
+ print_json(
316
+ call_api(
317
+ method="GET",
318
+ path="/api/v1/fn/functions",
319
+ profile=self.profile,
320
+ require_scope=True,
321
+ params={"limit": "50", "offset": "0"},
322
+ )
323
+ )
324
+ elif parts[0] == "logs" and len(parts) >= 2:
325
+ from urllib.parse import quote
326
+
327
+ name = quote(parts[1], safe="")
328
+ print_json(
329
+ call_api(
330
+ method="GET",
331
+ path=f"/api/v1/fn/functions/{name}/logs",
332
+ profile=self.profile,
333
+ require_scope=True,
334
+ params={"limit": "20", "offset": "0"},
335
+ )
336
+ )
337
+ else:
338
+ typer.echo("Usage: fn list | fn logs <name>")
339
+ except CLIRequestError as exc:
340
+ print_error(str(exc))
341
+
342
+ def do_db(self, arg: str) -> None:
343
+ parts = shlex.split(arg)
344
+ if len(parts) >= 3 and parts[0] == "migrations" and parts[1] == "list" and parts[2] == "--local":
345
+ from apps.cli.src.utils.migrations import list_local_migrations, load_applied_state
346
+
347
+ applied = load_applied_state()
348
+ for path in list_local_migrations():
349
+ marker = "✓" if path.name in applied else " "
350
+ typer.echo(f" [{marker}] {path.name}")
351
+ return
352
+ typer.echo("Usage: db migrations list --local")
353
+
354
+ def do_sessions(self, arg: str) -> None:
355
+ if arg.strip():
356
+ typer.echo("Usage: sessions")
357
+ return
358
+ try:
359
+ print_json(call_api(method="GET", path="/api/v1/auth/sessions", profile=self.profile))
360
+ except CLIRequestError as exc:
361
+ print_error(str(exc))
362
+
363
+ def do_api(self, arg: str) -> None:
364
+ parts = shlex.split(arg)
365
+ if len(parts) < 2:
366
+ typer.echo("Usage: api GET /api/v1/...")
367
+ return
368
+ method, path = parts[0].upper(), parts[1]
369
+ try:
370
+ print_json(
371
+ call_api(
372
+ method=method,
373
+ path=path,
374
+ profile=self.profile,
375
+ require_scope=True,
376
+ )
377
+ )
378
+ except CLIRequestError as exc:
379
+ print_error(str(exc))
380
+
381
+ def default(self, line: str) -> None:
382
+ if line.startswith("SELECT") or line.startswith("select") or line.startswith("WITH"):
383
+ self.do_sql(line)
384
+ return
385
+ typer.echo(f"Unknown command: {line.split()[0]}. Type `help`.")
386
+
387
+
388
+ @app.callback()
389
+ def shell(
390
+ profile: str | None = typer.Option(None, "--profile"),
391
+ ) -> None:
392
+ """Launch an interactive Sovaria REPL with SQL, sim, keys, policy, and more."""
393
+ if not load_tokens(profile=profile):
394
+ print_error("Not logged in. Run: sov login")
395
+ raise typer.Exit(1)
396
+
397
+ _setup_readline()
398
+
399
+ status = project_status()
400
+ name = status.get("project_name") if isinstance(status, dict) else None
401
+ print_banner("Sovaria Shell", name or "Interactive project terminal")
402
+ if isinstance(status, dict) and status.get("linked"):
403
+ scope = status.get("scope") or {}
404
+ typer.echo(
405
+ f"Linked: {scope.get('org_id', '?')} / {scope.get('project_id', '?')}"
406
+ )
407
+ print_success("Scope resolved from sovaria.config.json")
408
+ else:
409
+ typer.echo("No local project link. Run `sov init` or `sov project link`.")
410
+ typer.echo("Type `help` for commands.\n")
411
+
412
+ try:
413
+ SovariaShell(profile=profile).cmdloop()
414
+ except KeyboardInterrupt:
415
+ typer.echo("\nBye.")
@@ -0,0 +1,178 @@
1
+ """Phase 2 simulation commands (Echo engine)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ import typer
8
+
9
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg
10
+ from apps.cli.src.utils.display import print_json
11
+
12
+ app = typer.Typer(help="Scenario discovery, runs, and comparisons")
13
+
14
+
15
+ @app.command("discover")
16
+ def discover(
17
+ profile: str | None = typer.Option(None, "--profile"),
18
+ org_id: str | None = typer.Option(None, "--org"),
19
+ project_id: str | None = typer.Option(None, "--project"),
20
+ env_id: str | None = typer.Option(None, "--env"),
21
+ ) -> None:
22
+ """Discover runnable simulation scenarios for the linked project."""
23
+ try:
24
+ result = call_api(
25
+ method="POST",
26
+ path="/api/v1/sim/discover",
27
+ profile=profile,
28
+ org_id=org_id,
29
+ project_id=project_id,
30
+ env_id=env_id,
31
+ require_scope=True,
32
+ body={},
33
+ )
34
+ except CLIRequestError as exc:
35
+ typer.echo(str(exc), err=True)
36
+ raise typer.Exit(1) from exc
37
+ print_json(result)
38
+
39
+
40
+ @app.command("runs")
41
+ def list_runs(
42
+ limit: int = typer.Option(20, "--limit"),
43
+ offset: int = typer.Option(0, "--offset"),
44
+ profile: str | None = typer.Option(None, "--profile"),
45
+ org_id: str | None = typer.Option(None, "--org"),
46
+ project_id: str | None = typer.Option(None, "--project"),
47
+ env_id: str | None = typer.Option(None, "--env"),
48
+ ) -> None:
49
+ """List simulation runs for the current project."""
50
+ try:
51
+ result = call_api(
52
+ method="GET",
53
+ path="/api/v1/sim/runs",
54
+ profile=profile,
55
+ org_id=org_id,
56
+ project_id=project_id,
57
+ env_id=env_id,
58
+ require_scope=True,
59
+ params={"limit": str(limit), "offset": str(offset)},
60
+ )
61
+ except CLIRequestError as exc:
62
+ typer.echo(str(exc), err=True)
63
+ raise typer.Exit(1) from exc
64
+ print_json(result)
65
+
66
+
67
+ @app.command("get")
68
+ def get_run(
69
+ run_id: str = typer.Argument(..., help="Simulation run ID"),
70
+ profile: str | None = typer.Option(None, "--profile"),
71
+ org_id: str | None = typer.Option(None, "--org"),
72
+ project_id: str | None = typer.Option(None, "--project"),
73
+ env_id: str | None = typer.Option(None, "--env"),
74
+ ) -> None:
75
+ """Fetch one simulation run by ID."""
76
+ try:
77
+ result = call_api(
78
+ method="GET",
79
+ path=f"/api/v1/sim/runs/{quote(run_id, safe='')}",
80
+ profile=profile,
81
+ org_id=org_id,
82
+ project_id=project_id,
83
+ env_id=env_id,
84
+ require_scope=True,
85
+ )
86
+ except CLIRequestError as exc:
87
+ typer.echo(str(exc), err=True)
88
+ raise typer.Exit(1) from exc
89
+ print_json(result)
90
+
91
+
92
+ @app.command("compare")
93
+ def compare_runs(
94
+ target_run_id: str = typer.Argument(..., help="Run to inspect"),
95
+ baseline_run_id: str = typer.Argument(..., help="Baseline run"),
96
+ profile: str | None = typer.Option(None, "--profile"),
97
+ org_id: str | None = typer.Option(None, "--org"),
98
+ project_id: str | None = typer.Option(None, "--project"),
99
+ env_id: str | None = typer.Option(None, "--env"),
100
+ ) -> None:
101
+ """Compare two simulation runs."""
102
+ try:
103
+ result = call_api(
104
+ method="GET",
105
+ path=(
106
+ f"/api/v1/sim/runs/{quote(target_run_id, safe='')}/compare/"
107
+ f"{quote(baseline_run_id, safe='')}"
108
+ ),
109
+ profile=profile,
110
+ org_id=org_id,
111
+ project_id=project_id,
112
+ env_id=env_id,
113
+ require_scope=True,
114
+ )
115
+ except CLIRequestError as exc:
116
+ typer.echo(str(exc), err=True)
117
+ raise typer.Exit(1) from exc
118
+ print_json(result)
119
+
120
+
121
+ @app.command("run")
122
+ def run_scenario(
123
+ scenario_id: str = typer.Argument(..., help="Scenario ID from `sov sim discover`"),
124
+ payload: str | None = typer.Option(None, "--payload", help="JSON object payload"),
125
+ profile: str | None = typer.Option(None, "--profile"),
126
+ org_id: str | None = typer.Option(None, "--org"),
127
+ project_id: str | None = typer.Option(None, "--project"),
128
+ env_id: str | None = typer.Option(None, "--env"),
129
+ ) -> None:
130
+ """Execute a discovered simulation scenario."""
131
+ body = parse_json_arg(payload, label="--payload") if payload else {}
132
+ if body is not None and not isinstance(body, dict):
133
+ raise typer.BadParameter("--payload must be a JSON object")
134
+ try:
135
+ result = call_api(
136
+ method="POST",
137
+ path="/api/v1/sim/run",
138
+ profile=profile,
139
+ org_id=org_id,
140
+ project_id=project_id,
141
+ env_id=env_id,
142
+ require_scope=True,
143
+ body={"scenario_id": scenario_id, **(body or {})},
144
+ )
145
+ except CLIRequestError as exc:
146
+ typer.echo(str(exc), err=True)
147
+ raise typer.Exit(1) from exc
148
+ print_json(result)
149
+
150
+
151
+ @app.command("plan")
152
+ def plan_scenario(
153
+ scenario_id: str = typer.Argument(..., help="Scenario ID"),
154
+ payload: str | None = typer.Option(None, "--payload", help="JSON object payload"),
155
+ profile: str | None = typer.Option(None, "--profile"),
156
+ org_id: str | None = typer.Option(None, "--org"),
157
+ project_id: str | None = typer.Option(None, "--project"),
158
+ env_id: str | None = typer.Option(None, "--env"),
159
+ ) -> None:
160
+ """Dry-run a scenario and return the execution plan."""
161
+ body = parse_json_arg(payload, label="--payload") if payload else {}
162
+ if body is not None and not isinstance(body, dict):
163
+ raise typer.BadParameter("--payload must be a JSON object")
164
+ try:
165
+ result = call_api(
166
+ method="POST",
167
+ path="/api/v1/sim/plan",
168
+ profile=profile,
169
+ org_id=org_id,
170
+ project_id=project_id,
171
+ env_id=env_id,
172
+ require_scope=True,
173
+ body={"scenario_id": scenario_id, **(body or {})},
174
+ )
175
+ except CLIRequestError as exc:
176
+ typer.echo(str(exc), err=True)
177
+ raise typer.Exit(1) from exc
178
+ print_json(result)