sovaria-cli 0.2.9__tar.gz → 0.3.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.
Files changed (113) hide show
  1. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/.gitignore +2 -0
  2. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/PKG-INFO +18 -3
  3. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/README.md +15 -0
  4. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/__init__.py +1 -1
  5. sovaria_cli-0.3.0/apps/cli/src/commands/ai_data.py +341 -0
  6. sovaria_cli-0.3.0/apps/cli/src/commands/alerts.py +142 -0
  7. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/api.py +10 -3
  8. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/audit.py +9 -13
  9. sovaria_cli-0.3.0/apps/cli/src/commands/auth_enterprise.py +158 -0
  10. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/auth_product.py +108 -4
  11. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/billing.py +102 -0
  12. sovaria_cli-0.3.0/apps/cli/src/commands/branches.py +240 -0
  13. sovaria_cli-0.3.0/apps/cli/src/commands/builds.py +128 -0
  14. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/chrono.py +50 -0
  15. sovaria_cli-0.3.0/apps/cli/src/commands/collections.py +289 -0
  16. sovaria_cli-0.3.0/apps/cli/src/commands/crashes.py +92 -0
  17. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/db.py +603 -41
  18. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/deploy.py +210 -120
  19. sovaria_cli-0.3.0/apps/cli/src/commands/env.py +176 -0
  20. sovaria_cli-0.3.0/apps/cli/src/commands/fn_platform.py +783 -0
  21. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/gen.py +26 -5
  22. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/governance.py +86 -0
  23. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/iam.py +9 -3
  24. sovaria_cli-0.3.0/apps/cli/src/commands/import_cmd.py +840 -0
  25. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/keys.py +24 -3
  26. sovaria_cli-0.3.0/apps/cli/src/commands/knowledge.py +214 -0
  27. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/login.py +19 -10
  28. sovaria_cli-0.3.0/apps/cli/src/commands/logs.py +197 -0
  29. sovaria_cli-0.3.0/apps/cli/src/commands/mcp.py +208 -0
  30. sovaria_cli-0.3.0/apps/cli/src/commands/messaging.py +200 -0
  31. sovaria_cli-0.3.0/apps/cli/src/commands/metrics.py +85 -0
  32. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/org.py +8 -4
  33. sovaria_cli-0.3.0/apps/cli/src/commands/org_key.py +96 -0
  34. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/policy.py +42 -9
  35. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/realtime.py +34 -0
  36. sovaria_cli-0.3.0/apps/cli/src/commands/rules.py +226 -0
  37. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/secrets.py +17 -10
  38. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/shell.py +49 -23
  39. sovaria_cli-0.3.0/apps/cli/src/commands/sim.py +511 -0
  40. sovaria_cli-0.3.0/apps/cli/src/commands/simulate.py +150 -0
  41. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/storage.py +196 -18
  42. sovaria_cli-0.3.0/apps/cli/src/commands/templates.py +162 -0
  43. sovaria_cli-0.3.0/apps/cli/src/commands/vector.py +395 -0
  44. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/main.py +92 -4
  45. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/api_client.py +174 -8
  46. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/auth_store.py +27 -4
  47. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/config.py +6 -5
  48. sovaria_cli-0.3.0/apps/cli/src/utils/forge_local.py +664 -0
  49. sovaria_cli-0.3.0/apps/cli/src/utils/importers.py +573 -0
  50. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/migrations.py +14 -2
  51. sovaria_cli-0.3.0/apps/cli/src/utils/mobile_types.py +322 -0
  52. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/premium_display.py +8 -3
  53. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/project_context.py +37 -0
  54. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/schema_introspect.py +1 -1
  55. sovaria_cli-0.3.0/apps/cli/src/utils/sim_offline.py +469 -0
  56. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/pyproject.toml +8 -3
  57. sovaria_cli-0.2.9/.github/dependabot.yml +0 -12
  58. sovaria_cli-0.2.9/.github/workflows/ci.yml +0 -174
  59. sovaria_cli-0.2.9/.github/workflows/publish.yml +0 -37
  60. sovaria_cli-0.2.9/.gitleaks.toml +0 -17
  61. sovaria_cli-0.2.9/.python-version +0 -1
  62. sovaria_cli-0.2.9/Makefile +0 -44
  63. sovaria_cli-0.2.9/apps/cli/src/commands/collections.py +0 -38
  64. sovaria_cli-0.2.9/apps/cli/src/commands/logs.py +0 -103
  65. sovaria_cli-0.2.9/apps/cli/src/commands/mcp.py +0 -79
  66. sovaria_cli-0.2.9/apps/cli/src/commands/sim.py +0 -216
  67. sovaria_cli-0.2.9/apps/cli/src/commands/vector.py +0 -207
  68. sovaria_cli-0.2.9/apps/cli/tests/test_aug18_cli_tickets.py +0 -450
  69. sovaria_cli-0.2.9/apps/cli/tests/test_auth_store_profiles.py +0 -103
  70. sovaria_cli-0.2.9/apps/cli/tests/test_cli_security.py +0 -110
  71. sovaria_cli-0.2.9/apps/cli/tests/test_cli_smoke_paths.py +0 -301
  72. sovaria_cli-0.2.9/apps/cli/tests/test_config_profiles.py +0 -82
  73. sovaria_cli-0.2.9/apps/cli/tests/test_credential_file_permissions.py +0 -29
  74. sovaria_cli-0.2.9/apps/cli/tests/test_db_migrations.py +0 -62
  75. sovaria_cli-0.2.9/apps/cli/tests/test_main_startup.py +0 -29
  76. sovaria_cli-0.2.9/apps/cli/tests/test_mcp_command.py +0 -31
  77. sovaria_cli-0.2.9/apps/cli/tests/test_phase4_commands.py +0 -91
  78. sovaria_cli-0.2.9/apps/cli/tests/test_phase5_commands.py +0 -97
  79. sovaria_cli-0.2.9/apps/cli/tests/test_phase6_commands.py +0 -133
  80. sovaria_cli-0.2.9/apps/cli/tests/test_premium_display.py +0 -132
  81. sovaria_cli-0.2.9/apps/cli/tests/test_project_and_commands.py +0 -257
  82. sovaria_cli-0.2.9/apps/cli/tests/test_scope_picker.py +0 -17
  83. sovaria_cli-0.2.9/apps/cli/tests/test_supabase_style_commands.py +0 -63
  84. sovaria_cli-0.2.9/pytest.ini +0 -3
  85. sovaria_cli-0.2.9/requirements-dev.txt +0 -4
  86. sovaria_cli-0.2.9/requirements.txt +0 -9
  87. sovaria_cli-0.2.9/scripts/cli_sdk_happy_path.sh +0 -163
  88. sovaria_cli-0.2.9/scripts/smoke_all_commands.sh +0 -107
  89. sovaria_cli-0.2.9/scripts/smoke_chunks.sh +0 -90
  90. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/__init__.py +0 -0
  91. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/__init__.py +0 -0
  92. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/admin.py +0 -0
  93. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/connect.py +0 -0
  94. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/context.py +0 -0
  95. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/dashboard.py +0 -0
  96. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/data.py +0 -0
  97. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/init_cmd.py +0 -0
  98. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/inspect.py +0 -0
  99. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/link_cmd.py +0 -0
  100. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/oidc_providers.py +0 -0
  101. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/passkeys.py +0 -0
  102. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/project.py +0 -0
  103. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/schema.py +0 -0
  104. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/scope.py +0 -0
  105. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/test_cmd.py +0 -0
  106. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/__init__.py +0 -0
  107. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/api_helpers.py +0 -0
  108. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/display.py +0 -0
  109. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/local_function_server.py +0 -0
  110. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/log_tail.py +0 -0
  111. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/output_mode.py +0 -0
  112. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/scope_picker.py +0 -0
  113. {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/theme.py +0 -0
@@ -8,3 +8,5 @@ dist/
8
8
  .env.*
9
9
  !.env.example
10
10
  !.env.*.example
11
+
12
+ .venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sovaria-cli
3
- Version: 0.2.9
3
+ Version: 0.3.0
4
4
  Summary: Sovaria BaaS command-line interface (sov)
5
5
  Project-URL: Homepage, https://sovaria.in
6
6
  Project-URL: Documentation, https://docs.sovaria.in/cli
@@ -12,7 +12,7 @@ Requires-Dist: httpx>=0.28.1
12
12
  Requires-Dist: keyring>=25.0
13
13
  Requires-Dist: pyyaml>=6.0
14
14
  Requires-Dist: rich>=13.7.0
15
- Requires-Dist: sovaria-sdk==0.4.1
15
+ Requires-Dist: sovaria-sdk==0.5.0
16
16
  Requires-Dist: tomli-w>=1.2.0
17
17
  Requires-Dist: typer>=0.17.4
18
18
  Provides-Extra: dev
@@ -22,13 +22,22 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
22
22
  Requires-Dist: ruff>=0.9; extra == 'dev'
23
23
  Requires-Dist: twine>=6.0; extra == 'dev'
24
24
  Provides-Extra: mcp
25
- Requires-Dist: sovaria-sdk[mcp]==0.4.1; extra == 'mcp'
25
+ Requires-Dist: sovaria-sdk[mcp]==0.5.0; extra == 'mcp'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # Sovaria CLI
29
29
 
30
30
  Operator CLI for gateway-backed Sovaria project and platform workflows.
31
31
 
32
+ ## Install
33
+
34
+ ```bash
35
+ npm install -g @sovaria/cli # or: npx @sovaria/cli --help
36
+ pip install sovaria-cli # same CLI from PyPI
37
+ ```
38
+
39
+ Both need Python 3.11+. The npm package sets up a private environment in `~/.sovaria/npm-cli` on first run.
40
+
32
41
  ## Run
33
42
 
34
43
  Examples below use `sov ...` command syntax.
@@ -113,6 +122,12 @@ sov realtime ws-url my-channel --org <org_id> --project <project_id>
113
122
  - `sov fn ...` Forge function deploy/status/invoke
114
123
  - `sov project ...` project config template + scaffold creation
115
124
  - `sov api call ...` raw gateway API request
125
+ - `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging`, `use x`)
126
+ - `sov gen types typescript|python|dart|swift|kotlin -o <file>` row models from the live schema
127
+ - `sov storage cp [-r] <src> <dst>` copy between local paths and `ss://bucket/path` (or between buckets)
128
+ - `sov collections delete <path> [-r]` delete a collection or document (and its subcollections with `-r`)
129
+ - `sov auth-product export users.json|users.csv` every app user (profile fields only)
130
+ - `sov functions new <name> --runtime python|node|typescript|go` a function folder with a starter handler
116
131
 
117
132
  ## Project Bootstrap (Config JSON)
118
133
 
@@ -2,6 +2,15 @@
2
2
 
3
3
  Operator CLI for gateway-backed Sovaria project and platform workflows.
4
4
 
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @sovaria/cli # or: npx @sovaria/cli --help
9
+ pip install sovaria-cli # same CLI from PyPI
10
+ ```
11
+
12
+ Both need Python 3.11+. The npm package sets up a private environment in `~/.sovaria/npm-cli` on first run.
13
+
5
14
  ## Run
6
15
 
7
16
  Examples below use `sov ...` command syntax.
@@ -86,6 +95,12 @@ sov realtime ws-url my-channel --org <org_id> --project <project_id>
86
95
  - `sov fn ...` Forge function deploy/status/invoke
87
96
  - `sov project ...` project config template + scaffold creation
88
97
  - `sov api call ...` raw gateway API request
98
+ - `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging`, `use x`)
99
+ - `sov gen types typescript|python|dart|swift|kotlin -o <file>` row models from the live schema
100
+ - `sov storage cp [-r] <src> <dst>` copy between local paths and `ss://bucket/path` (or between buckets)
101
+ - `sov collections delete <path> [-r]` delete a collection or document (and its subcollections with `-r`)
102
+ - `sov auth-product export users.json|users.csv` every app user (profile fields only)
103
+ - `sov functions new <name> --runtime python|node|typescript|go` a function folder with a starter handler
89
104
 
90
105
  ## Project Bootstrap (Config JSON)
91
106
 
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.2.9"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,341 @@
1
+ """AI & data commands (W9), attached to existing groups without editing them:
2
+
3
+ * ``sov db advisor`` — index advisor (hypothetical indexes), ``apply`` / ``status``;
4
+ * ``sov db insights`` — query insights (pg_stat_statements) and ``reset``;
5
+ * ``sov collections pipeline`` — document pipelines (where / aggregate / sort / limit / select …);
6
+ * ``sov agents versions | publish | rollback | compare`` — agent versions;
7
+ * ``sov gateway`` — the project's model gateway: routes, completions, usage, cache.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from urllib.parse import quote
15
+
16
+ import typer
17
+
18
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
19
+ from apps.cli.src.utils.display import handle_cli_error, print_json
20
+
21
+ _ORG = typer.Option(None, "--org")
22
+ _PROJECT = typer.Option(None, "--project")
23
+ _ENV = typer.Option(None, "--env")
24
+ _PROFILE = typer.Option(None, "--profile")
25
+
26
+
27
+ def _seg(value: str) -> str:
28
+ return quote(str(value), safe="")
29
+
30
+
31
+ def _call(method: str, path: str, *, profile, org_id, project_id, env_id=None, body=None, params=None,
32
+ timeout: float = 60.0):
33
+ try:
34
+ return call_api(method=method, path=path, profile=profile, org_id=org_id, project_id=project_id,
35
+ env_id=env_id, require_scope=True, body=body, params=params, timeout=timeout)
36
+ except CLIRequestError as exc:
37
+ handle_cli_error(exc)
38
+ raise typer.Exit(1) from exc
39
+
40
+
41
+ def _db_path(profile, org_id, project_id, suffix: str) -> str:
42
+ from apps.cli.src.utils.project_context import resolve_scope
43
+
44
+ org, project, _env = resolve_scope(profile=profile, org_id=org_id, project_id=project_id, env_id=None, require=True)
45
+ return f"/api/v1/governance/tenants/{_seg(org)}/databases/{_seg(project)}/{suffix}"
46
+
47
+
48
+ # ── sov db advisor / insights ──────────────────────────────────────────────────────────────────
49
+
50
+ advisor_app = typer.Typer(help="Index advisor: indexes that would speed up the slowest queries, tested as "
51
+ "hypothetical indexes first", invoke_without_command=True)
52
+ insights_app = typer.Typer(help="Query insights: the project's statements by total time, average, calls…",
53
+ invoke_without_command=True)
54
+
55
+
56
+ @advisor_app.callback()
57
+ def advisor(
58
+ ctx: typer.Context,
59
+ queryid: str | None = typer.Option(None, "--queryid", help="Only this statement (from sov db insights)"),
60
+ as_json: bool = typer.Option(False, "--json"),
61
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
62
+ ) -> None:
63
+ """Recommend indexes for the project's most expensive statements."""
64
+ if ctx.invoked_subcommand:
65
+ return
66
+ out = _call("GET", _db_path(profile, org_id, project_id, "index-advisor"), profile=profile, org_id=org_id,
67
+ project_id=project_id, params={"queryid": queryid} if queryid else None, timeout=120)
68
+ if as_json:
69
+ print_json(out)
70
+ return
71
+ _print_recommendations(out)
72
+
73
+
74
+ def _print_recommendations(out: dict) -> None:
75
+ mode = "tested with hypothetical indexes" if out.get("mode") == "hypopg" else "unverified (no hypopg here)"
76
+ recs = out.get("recommendations") or []
77
+ typer.echo(f"{len(recs)} recommendation(s), {mode}, {len(out.get('statements') or [])} statement(s) examined.")
78
+ for w in out.get("warnings") or []:
79
+ typer.echo(f"! {w}", err=True)
80
+ for r in recs:
81
+ best = max((s.get("gain_pct") or 0 for s in r.get("statements") or []), default=0)
82
+ typer.echo(f"\n {r['schema']}.{r['table']} ({', '.join(r['columns'])})"
83
+ + (f" — plan cost −{best}%" if best else "") + f", {r.get('table_rows', 0):,} rows")
84
+ typer.echo(f" {r['sql']}")
85
+ for s in (r.get("statements") or [])[:3]:
86
+ typer.echo(f" · {s['query'][:110]}")
87
+ if recs and (out.get("limits") or {}).get("apply"):
88
+ r = recs[0]
89
+ typer.echo(f"\nApply: sov db advisor apply --table {r['schema']}.{r['table']} --columns {','.join(r['columns'])}")
90
+
91
+
92
+ @advisor_app.command("query")
93
+ def advisor_query(
94
+ sql: str = typer.Argument(..., help="One SELECT / WITH / UPDATE / DELETE (only EXPLAINed, never run)"),
95
+ as_json: bool = typer.Option(False, "--json"),
96
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
97
+ ) -> None:
98
+ """Recommend indexes for one statement."""
99
+ out = _call("POST", _db_path(profile, org_id, project_id, "index-advisor/query"), profile=profile, org_id=org_id,
100
+ project_id=project_id, body={"query": sql})
101
+ print_json(out) if as_json else _print_recommendations(out)
102
+
103
+
104
+ @advisor_app.command("apply")
105
+ def advisor_apply(
106
+ table: str = typer.Option(..., "--table", help="schema.table (public. may be left out)"),
107
+ columns: str = typer.Option(..., "--columns", help="Comma list, in index order (1-3)"),
108
+ yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask for confirmation"),
109
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
110
+ ) -> None:
111
+ """Build a recommended index with CREATE INDEX CONCURRENTLY (no write lock); follow it with `status`."""
112
+ schema, _, name = table.rpartition(".")
113
+ cols = [c.strip() for c in columns.split(",") if c.strip()]
114
+ if not yes and not typer.confirm(f"Create an index on {schema or 'public'}.{name} ({', '.join(cols)})?"):
115
+ raise typer.Exit(1)
116
+ out = _call("POST", _db_path(profile, org_id, project_id, "index-advisor/apply"), profile=profile, org_id=org_id,
117
+ project_id=project_id, body={"schema": schema or "public", "table": name, "columns": cols})
118
+ typer.echo(f"{out['index_name']}: {out['status']} (sov db advisor status {out['index_name']})")
119
+
120
+
121
+ @advisor_app.command("status")
122
+ def advisor_status(
123
+ index_name: str = typer.Argument(...),
124
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
125
+ ) -> None:
126
+ """Whether an index the advisor started is building, ready, failed or invalid."""
127
+ print_json(_call("GET", _db_path(profile, org_id, project_id, f"index-advisor/indexes/{_seg(index_name)}"),
128
+ profile=profile, org_id=org_id, project_id=project_id))
129
+
130
+
131
+ @insights_app.callback()
132
+ def insights(
133
+ ctx: typer.Context,
134
+ sort: str = typer.Option("total", "--sort", help="total, mean, calls, max or rows"),
135
+ limit: int = typer.Option(20, "--limit", min=1, max=1000),
136
+ as_json: bool = typer.Option(False, "--json"),
137
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
138
+ ) -> None:
139
+ """The project's statements from pg_stat_statements (plan caps the rows: db.query_insights_rows)."""
140
+ if ctx.invoked_subcommand:
141
+ return
142
+ out = _call("GET", _db_path(profile, org_id, project_id, "query-insights"), profile=profile, org_id=org_id,
143
+ project_id=project_id, params={"sort": sort, "limit": str(limit)})
144
+ if as_json or not out.get("enabled"):
145
+ print_json(out)
146
+ return
147
+ typer.echo(f"{'calls':>9} {'total ms':>11} {'mean ms':>9} {'max ms':>9} {'hit %':>6} query")
148
+ for s in out.get("statements") or []:
149
+ hit = "" if s.get("cache_hit_pct") is None else f"{s['cache_hit_pct']:.0f}"
150
+ flag = "*" if s.get("slow") else " "
151
+ typer.echo(f"{s['calls']:>9,} {s['total_ms']:>11,.1f} {s['mean_ms']:>9.1f} {s['max_ms']:>9.1f} {hit:>6} {flag}"
152
+ f"{' '.join(s['query'].split())[:90]} [{s['queryid']}]")
153
+ typer.echo(f"* slower than {out.get('slow_ms')} ms on average · since {out.get('since') or 'the last reset'}")
154
+
155
+
156
+ @insights_app.command("reset")
157
+ def insights_reset(
158
+ yes: bool = typer.Option(False, "--yes", "-y"),
159
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
160
+ ) -> None:
161
+ """Clear this database's query statistics."""
162
+ if not yes and not typer.confirm("Clear the query statistics of this project's database?"):
163
+ raise typer.Exit(1)
164
+ print_json(_call("POST", _db_path(profile, org_id, project_id, "query-insights/reset"), profile=profile,
165
+ org_id=org_id, project_id=project_id, body={}))
166
+
167
+
168
+ # ── sov collections pipeline ───────────────────────────────────────────────────────────────────
169
+
170
+
171
+ def pipeline(
172
+ collection: str = typer.Argument(..., help="Collection path, or the group id with --group"),
173
+ stages_file: Path | None = typer.Option(None, "--file", "-f", help="JSON file: a list of stages or {\"stages\": [...]}"),
174
+ stages: str | None = typer.Option(None, "--stages", help="Stages as inline JSON"),
175
+ group: bool = typer.Option(False, "--group", help="Run over the collection group (every collection with this id)"),
176
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
177
+ env_id: str | None = _ENV,
178
+ ) -> None:
179
+ """Run a document pipeline: where, aggregate, distinct, sort, offset, limit, select, remove_fields, sample.
180
+
181
+ Example: sov collections pipeline orders --stages '[{"where": ["status","==","paid"]},
182
+ {"aggregate": {"accumulators": [{"op": "sum", "field": "amount", "alias": "total"}], "groups": ["country"]}},
183
+ {"sort": [["total","desc"]]}, {"limit": 5}]'"""
184
+ raw = stages_file.read_text(encoding="utf-8") if stages_file else stages
185
+ if not raw:
186
+ raise typer.BadParameter("Give the stages with --file or --stages")
187
+ try:
188
+ parsed = json.loads(raw)
189
+ except ValueError as exc:
190
+ raise typer.BadParameter(f"Stages aren't valid JSON: {exc}") from exc
191
+ body = parsed if isinstance(parsed, dict) else {"stages": parsed}
192
+ if group:
193
+ body["collection_group"] = True
194
+ print_json(_call("POST", f"/api/v1/data/doc/_pipeline/{quote(collection, safe='/')}", profile=profile,
195
+ org_id=org_id, project_id=project_id, env_id=env_id, body=body))
196
+
197
+
198
+ # ── sov agents versions / publish / rollback / compare ─────────────────────────────────────────
199
+
200
+
201
+ def agent_versions(agent_id: str = typer.Argument(...), profile: str | None = _PROFILE, org_id: str | None = _ORG,
202
+ project_id: str | None = _PROJECT) -> None:
203
+ """An agent's published version and its kept versions."""
204
+ print_json(_call("GET", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions", profile=profile, org_id=org_id,
205
+ project_id=project_id))
206
+
207
+
208
+ def agent_publish(agent_id: str = typer.Argument(...), note: str = typer.Option("", "--note", "-m"),
209
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
210
+ """Publish the agent's current definition; app users run it from now on."""
211
+ out = _call("POST", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions", profile=profile, org_id=org_id,
212
+ project_id=project_id, body={"note": note})
213
+ typer.echo(f"Published version {out['version']}")
214
+
215
+
216
+ def agent_rollback(agent_id: str = typer.Argument(...), version: int = typer.Argument(...),
217
+ note: str = typer.Option("", "--note", "-m"), yes: bool = typer.Option(False, "--yes", "-y"),
218
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
219
+ """Make an older version current again (it is published as a new version)."""
220
+ if not yes and not typer.confirm(f"Roll agent {agent_id} back to version {version}? It replaces the draft too."):
221
+ raise typer.Exit(1)
222
+ out = _call("POST", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions/{version}/rollback", profile=profile,
223
+ org_id=org_id, project_id=project_id, body={"note": note})
224
+ typer.echo(f"Rolled back to version {out['rolled_back_to']} (published as version {out['version']})")
225
+
226
+
227
+ def agent_compare(agent_id: str = typer.Argument(...), a: str = typer.Argument(..., help="Version number"),
228
+ b: str = typer.Argument("draft", help="Version number or 'draft'"),
229
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
230
+ """What changed between two versions (or a version and the draft)."""
231
+ out = _call("GET", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions/compare", profile=profile, org_id=org_id,
232
+ project_id=project_id, params={"a": a, "b": b})
233
+ typer.echo(f"Changed: {', '.join(out.get('changed_fields') or []) or 'nothing'}")
234
+ for key in ("custom_tools", "tools", "knowledge_bases", "playbooks", "mcp_servers"):
235
+ if key in out:
236
+ typer.echo(f" {key}: +{out[key]['added']} −{out[key]['removed']}")
237
+ if out.get("model"):
238
+ typer.echo(f" model: {out['model']['before']} → {out['model']['after']}")
239
+ for line in out.get("instructions_diff") or []:
240
+ typer.echo(f" {line}")
241
+
242
+
243
+ # ── sov gateway ────────────────────────────────────────────────────────────────────────────────
244
+
245
+ gateway_app = typer.Typer(help="Model gateway: routes over your own model keys, with fallbacks, a cache and usage per model")
246
+
247
+
248
+ @gateway_app.command("routes")
249
+ def gateway_routes(profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
250
+ """Routes, the models they may use and the plan's limits."""
251
+ print_json(_call("GET", "/api/v1/sovariq/gateway", profile=profile, org_id=org_id, project_id=project_id))
252
+
253
+
254
+ @gateway_app.command("set")
255
+ def gateway_set(
256
+ name: str = typer.Argument(...),
257
+ models: str = typer.Option(..., "--models", help="provider/model, first one answers, the rest are fallbacks"),
258
+ cache_ttl: int = typer.Option(0, "--cache-ttl", help="Cache identical requests this many seconds (0 = off)"),
259
+ retry_on: str | None = typer.Option(None, "--retry-on", help="Comma list: rate_limit, server_error, timeout, auth"),
260
+ app_users: bool = typer.Option(False, "--app-users", help="Let the app's signed-in users call this route"),
261
+ daily_per_user: int = typer.Option(200, "--daily-per-user", help="Requests one app user may make a day"),
262
+ max_output_tokens: int | None = typer.Option(None, "--max-output-tokens"),
263
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
264
+ ) -> None:
265
+ """Create or change a route."""
266
+ body: dict = {"models": [m.strip() for m in models.split(",") if m.strip()],
267
+ "cache": {"enabled": cache_ttl > 0, "ttl_s": cache_ttl},
268
+ "access": "app_users" if app_users else "members", "daily_requests_per_user": daily_per_user,
269
+ "max_output_tokens": max_output_tokens}
270
+ if retry_on:
271
+ body["retry_on"] = [r.strip() for r in retry_on.split(",") if r.strip()]
272
+ print_json(_call("PUT", f"/api/v1/sovariq/gateway/routes/{_seg(name)}", profile=profile, org_id=org_id,
273
+ project_id=project_id, body=body))
274
+
275
+
276
+ @gateway_app.command("delete")
277
+ def gateway_delete(name: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"),
278
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
279
+ if not yes and not typer.confirm(f"Delete route {name}?"):
280
+ raise typer.Exit(1)
281
+ print_json(_call("DELETE", f"/api/v1/sovariq/gateway/routes/{_seg(name)}", profile=profile, org_id=org_id,
282
+ project_id=project_id))
283
+
284
+
285
+ @gateway_app.command("complete")
286
+ def gateway_complete(
287
+ route: str = typer.Argument(...),
288
+ prompt: str = typer.Argument(..., help="The user message"),
289
+ system: str = typer.Option("", "--system"),
290
+ max_tokens: int = typer.Option(1024, "--max-tokens"),
291
+ no_cache: bool = typer.Option(False, "--no-cache"),
292
+ as_json: bool = typer.Option(False, "--json"),
293
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
294
+ ) -> None:
295
+ """One completion on a route (shows which model answered and whether it came from the cache)."""
296
+ out = _call("POST", f"/api/v1/sovariq/gateway/{_seg(route)}/complete", profile=profile, org_id=org_id,
297
+ project_id=project_id, timeout=180,
298
+ body={"system": system, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens,
299
+ "cache": not no_cache})
300
+ if as_json:
301
+ print_json(out)
302
+ return
303
+ typer.echo(out.get("text") or "")
304
+ tried = " → ".join(f"{a['model']}{'' if a['ok'] else ' (' + str(a.get('error')) + ')'}" for a in out.get("attempts") or [])
305
+ typer.echo(f"\n[{out.get('model')}{', cached' if out.get('cached') else ''}{'; ' + tried if tried else ''}]", err=True)
306
+
307
+
308
+ @gateway_app.command("usage")
309
+ def gateway_usage(days: int = typer.Option(7, "--days", min=1, max=90), as_json: bool = typer.Option(False, "--json"),
310
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
311
+ """Requests, errors, fallbacks, cache hits, tokens and latency per model."""
312
+ out = _call("GET", "/api/v1/sovariq/gateway/usage", profile=profile, org_id=org_id, project_id=project_id,
313
+ params={"days": str(days)})
314
+ if as_json:
315
+ print_json(out)
316
+ return
317
+ typer.echo(f"{'model':<40} {'requests':>9} {'errors':>7} {'fallbacks':>9} {'cached':>7} {'tokens in/out':>16} {'avg ms':>7}")
318
+ for model, m in sorted((out.get("models") or {}).items()):
319
+ typer.echo(f"{model:<40} {m['requests']:>9,} {m['errors']:>7,} {m['fallbacks']:>9,} {m['cache_hits']:>7,} "
320
+ f"{m['input_tokens']:>8,}/{m['output_tokens']:<7,} {m['avg_latency_ms']:>7,}")
321
+
322
+
323
+ @gateway_app.command("cache-clear")
324
+ def gateway_cache_clear(profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
325
+ print_json(_call("POST", "/api/v1/sovariq/gateway/cache/clear", profile=profile, org_id=org_id, project_id=project_id))
326
+
327
+
328
+ def register(main_app: typer.Typer) -> None:
329
+ """Attach the commands to their groups (called once from main)."""
330
+ from apps.cli.src.commands import collections as collections_mod
331
+ from apps.cli.src.commands import db as db_mod
332
+ from apps.cli.src.commands import knowledge as knowledge_mod
333
+
334
+ db_mod.app.add_typer(advisor_app, name="advisor")
335
+ db_mod.app.add_typer(insights_app, name="insights")
336
+ collections_mod.app.command("pipeline")(pipeline)
337
+ knowledge_mod.agents_app.command("versions")(agent_versions)
338
+ knowledge_mod.agents_app.command("publish")(agent_publish)
339
+ knowledge_mod.agents_app.command("rollback")(agent_rollback)
340
+ knowledge_mod.agents_app.command("compare")(agent_compare)
341
+ main_app.add_typer(gateway_app, name="gateway", help="Model gateway: routes, fallbacks, cache, usage per model")
@@ -0,0 +1,142 @@
1
+ """`sov alerts`: SLO alerts — email the team or a signed webhook when latency, 5xx rate, function failures or
2
+ database connections cross a threshold (Observability › Alerts).
3
+
4
+ sov alerts list
5
+ sov alerts create --metric latency_p95_ms --threshold 800 [--window 15] [--target "GET /rest/v1/orders"]
6
+ [--no-email] [--webhook https://hooks.example.com/sovaria]
7
+ sov alerts pause|resume <rule-id>
8
+ sov alerts test <rule-id>
9
+ sov alerts delete <rule-id>
10
+ sov alerts events [--limit 50]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from urllib.parse import quote
16
+
17
+ import typer
18
+
19
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
20
+ from apps.cli.src.utils.display import handle_cli_error, print_json, print_success
21
+ from apps.cli.src.utils.output_mode import wants_json
22
+
23
+ app = typer.Typer(help="SLO alerts: latency, 5xx rate, function failures, DB connections → email / signed webhook")
24
+
25
+ METRICS = ("latency_p95_ms", "latency_p99_ms", "error_rate", "function_failures", "db_connections")
26
+ BASE = "/api/v1/observability/alerts"
27
+
28
+
29
+ def _opts(profile, org_id, project_id, env_id) -> dict:
30
+ return {"profile": profile, "org_id": org_id, "project_id": project_id, "env_id": env_id, "require_scope": True}
31
+
32
+
33
+ def _call(method: str, path: str, opts: dict, **kw) -> dict:
34
+ try:
35
+ out = call_api(method=method, path=path, **opts, **kw)
36
+ except CLIRequestError as exc:
37
+ handle_cli_error(exc)
38
+ raise typer.Exit(1) from exc
39
+ return out if isinstance(out, dict) else {}
40
+
41
+
42
+ def _rid(rule_id: str) -> str:
43
+ return f"{BASE}/{quote(rule_id.strip(), safe='')}"
44
+
45
+
46
+ Profile = typer.Option(None, "--profile")
47
+ Org = typer.Option(None, "--org")
48
+ Project = typer.Option(None, "--project")
49
+ Env = typer.Option(None, "--env")
50
+
51
+
52
+ @app.command("list")
53
+ def alerts_list(profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
54
+ """Alert rules with their state (ok, firing, no_data, paused) and the plan limit."""
55
+ data = _call("GET", BASE, _opts(profile, org_id, project_id, env_id))
56
+ if wants_json():
57
+ print_json(data)
58
+ return
59
+ rows = [{"id": r.get("id"), "name": r.get("name"), "metric": r.get("metric"),
60
+ "over": f"{r.get('threshold')} {r.get('unit') or ''}".strip(), "window": f"{r.get('window_minutes')} min",
61
+ "target": r.get("target") or "project", "state": r.get("state") if r.get("enabled") else "paused",
62
+ "value": r.get("value"), "to": "+".join(x for x in ("email" if r.get("email") else "", "webhook" if r.get("webhook_url") else "") if x)}
63
+ for r in data.get("rules", [])]
64
+ print_json(rows, title=f"Alert rules ({data.get('used', 0)} of {data.get('limit', 0)})")
65
+
66
+
67
+ @app.command("create")
68
+ def alerts_create(
69
+ metric: str = typer.Option(..., "--metric", help=", ".join(METRICS)),
70
+ threshold: float = typer.Option(..., "--threshold", help="ms for latency, percent for error_rate, a count otherwise"),
71
+ window: int = typer.Option(15, "--window", help="Minutes: 5, 10, 15, 30 or 60 (route alerts: 15+)"),
72
+ target: str | None = typer.Option(None, "--target", help="Route ('GET /rest/v1/orders') or function name; default: whole project"),
73
+ envs: str | None = typer.Option(None, "--envs", help="Route alerts: environment ids, comma-separated (default: all)"),
74
+ name: str | None = typer.Option(None, "--name"),
75
+ email: bool = typer.Option(True, "--email/--no-email", help="Email the project team"),
76
+ webhook: str | None = typer.Option(None, "--webhook", help="HTTPS URL that receives a signed POST"),
77
+ min_requests: int = typer.Option(20, "--min-requests", min=1, help="Request alerts: ignore windows with fewer requests"),
78
+ profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env,
79
+ ) -> None:
80
+ """Create an alert rule. A webhook's signing secret is printed once: store it to verify X-Sovaria-Signature."""
81
+ if metric not in METRICS:
82
+ raise typer.BadParameter(f"--metric must be one of {', '.join(METRICS)}")
83
+ body: dict = {"metric": metric, "threshold": threshold, "window_minutes": window, "email": email, "min_requests": min_requests}
84
+ for key, value in (("target", target), ("env", envs), ("name", name), ("webhook_url", webhook)):
85
+ if value:
86
+ body[key] = value
87
+ data = _call("POST", BASE, _opts(profile, org_id, project_id, env_id), body=body)
88
+ if data.get("signing_secret"):
89
+ typer.echo(f"Signing secret (shown once): {data['signing_secret']}")
90
+ print_json({k: v for k, v in data.items() if k != "signing_secret"}, title="Alert rule created")
91
+
92
+
93
+ def _toggle(rule_id: str, enabled: bool, opts: dict) -> None:
94
+ data = _call("PATCH", _rid(rule_id), opts, body={"enabled": enabled})
95
+ print_success(f"{data.get('name') or rule_id}: {'resumed' if enabled else 'paused'}")
96
+
97
+
98
+ @app.command("pause")
99
+ def alerts_pause(rule_id: str, profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
100
+ """Stop checking and notifying for a rule."""
101
+ _toggle(rule_id, False, _opts(profile, org_id, project_id, env_id))
102
+
103
+
104
+ @app.command("resume")
105
+ def alerts_resume(rule_id: str, profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
106
+ """Check and notify again."""
107
+ _toggle(rule_id, True, _opts(profile, org_id, project_id, env_id))
108
+
109
+
110
+ @app.command("test")
111
+ def alerts_test(rule_id: str, profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
112
+ """Measure the rule now and send a test to its webhook (no email)."""
113
+ data = _call("POST", f"{_rid(rule_id)}/test", _opts(profile, org_id, project_id, env_id))
114
+ value = data.get("value")
115
+ typer.echo(f"Now: {'no data' if value is None else value} — {'would fire' if data.get('would_fire') else 'would not fire'}"
116
+ + (f" ({data['note']})" if data.get("note") else ""))
117
+ hook = data.get("webhook")
118
+ if hook:
119
+ typer.echo("Webhook: delivered" if hook.get("ok") else f"Webhook failed: {hook.get('error')}")
120
+
121
+
122
+ @app.command("delete")
123
+ def alerts_delete(
124
+ rule_id: str,
125
+ yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask"),
126
+ profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env,
127
+ ) -> None:
128
+ """Delete an alert rule."""
129
+ if not yes and not typer.confirm(f"Delete alert rule {rule_id}?"):
130
+ raise typer.Exit(1)
131
+ _call("DELETE", _rid(rule_id), _opts(profile, org_id, project_id, env_id))
132
+ print_success(f"Deleted {rule_id}")
133
+
134
+
135
+ @app.command("events")
136
+ def alerts_events(
137
+ limit: int = typer.Option(50, "--limit", min=1, max=200),
138
+ profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env,
139
+ ) -> None:
140
+ """Fired and resolved alerts, newest first."""
141
+ data = _call("GET", f"{BASE}/events", _opts(profile, org_id, project_id, env_id), params={"limit": str(limit)})
142
+ print_json(data.get("events", []), title="Alert events")
@@ -13,7 +13,7 @@ app = typer.Typer(help="Raw gateway API calls")
13
13
  @app.command("call")
14
14
  def call_command(
15
15
  method: str = typer.Argument(..., help="HTTP method, e.g. GET/POST"),
16
- path: str = typer.Argument(..., help="Gateway path, e.g. /api/v1/system/engines/health"),
16
+ path: str = typer.Argument(..., help="Gateway path, e.g. /api/v1/auth/me"),
17
17
  body: str | None = typer.Option(None, "--body", help="JSON request body"),
18
18
  query: str | None = typer.Option(None, "--query", help="JSON object query params"),
19
19
  profile: str | None = typer.Option(None, "--profile"),
@@ -30,12 +30,19 @@ def call_command(
30
30
  if parsed_query is not None and not isinstance(parsed_query, dict):
31
31
  raise CLIRequestError("--query must be a JSON object")
32
32
 
33
+ # A query string written in the path (`/x?limit=4`) is kept: the HTTP client would
34
+ # otherwise replace it with --query's params.
35
+ from urllib.parse import parse_qsl
36
+
37
+ bare_path, _, inline = path.partition("?")
38
+ params = dict(parse_qsl(inline, keep_blank_values=True))
39
+ params.update({str(k): str(v) for k, v in (parsed_query or {}).items()})
33
40
  result = call_api(
34
41
  method=method,
35
- path=path,
42
+ path=bare_path,
36
43
  profile=profile,
37
44
  body=parsed_body,
38
- params={str(k): str(v) for k, v in (parsed_query or {}).items()},
45
+ params=params,
39
46
  require_auth=not no_auth,
40
47
  require_scope=require_scope,
41
48
  org_id=org_id,
@@ -122,20 +122,16 @@ def sim_run(
122
122
  if parsed_context is not None and not isinstance(parsed_context, dict):
123
123
  raise CLIRequestError("--context must be a JSON object")
124
124
 
125
- premium_api(
126
- f"Running simulation '{simulation_name}'…",
127
- success="Completed",
125
+ # The name/operations are accepted for older scripts; the run covers the whole project
126
+ # (started in the background and polled — a full run outlasts one request).
127
+ from apps.cli.src.commands.sim import run_simulation_api
128
+ from apps.cli.src.utils.display import print_json
129
+ from apps.cli.src.utils.premium_display import run_with_spinner
130
+
131
+ print_json(
132
+ run_with_spinner(f"Running simulation '{simulation_name}'…", lambda: run_simulation_api(profile=profile),
133
+ success="Completed"),
128
134
  title="Simulation",
129
- method="POST",
130
- path="/api/v1/sim/run",
131
- profile=profile,
132
- body={
133
- "simulation_name": simulation_name,
134
- "operations": parsed_ops,
135
- "echo_flag": True,
136
- "include_execution_tree": include_execution_tree,
137
- "context": parsed_context or {},
138
- },
139
135
  )
140
136
 
141
137